Merge branch 'tapframe:main' into feature/ani-skip

This commit is contained in:
paregi12 2026-01-07 07:47:50 +05:30 committed by GitHub
commit a383289457
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 10725 additions and 6433 deletions

View file

@ -13,6 +13,7 @@ import {
Platform, Platform,
LogBox LogBox
} from 'react-native'; } from 'react-native';
import './src/i18n'; // Initialize i18n
import { NavigationContainer } from '@react-navigation/native'; import { NavigationContainer } from '@react-navigation/native';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';

View file

@ -95,8 +95,8 @@ android {
applicationId 'com.nuvio.app' applicationId 'com.nuvio.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 31 versionCode 32
versionName "1.3.3" versionName "1.3.4"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
} }
@ -118,7 +118,7 @@ android {
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4] def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
applicationVariants.all { variant -> applicationVariants.all { variant ->
variant.outputs.each { output -> variant.outputs.each { output ->
def baseVersionCode = 31 // Current versionCode 31 from defaultConfig def baseVersionCode = 32 // Current versionCode 32 from defaultConfig
def abiName = output.getFilter(com.android.build.OutputFile.ABI) def abiName = output.getFilter(com.android.build.OutputFile.ABI)
def versionCode = baseVersionCode * 100 // Base multiplier def versionCode = baseVersionCode * 100 // Base multiplier

View file

@ -3,5 +3,5 @@
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string> <string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string> <string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string> <string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
<string name="expo_runtime_version">1.3.3</string> <string name="expo_runtime_version">1.3.4</string>
</resources> </resources>

View file

@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Nuvio", "name": "Nuvio",
"slug": "nuvio", "slug": "nuvio",
"version": "1.3.3", "version": "1.3.4",
"orientation": "default", "orientation": "default",
"backgroundColor": "#020404", "backgroundColor": "#020404",
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
@ -17,7 +17,7 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
"buildNumber": "31", "buildNumber": "32",
"infoPlist": { "infoPlist": {
"NSAppTransportSecurity": { "NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true "NSAllowsArbitraryLoads": true
@ -51,7 +51,7 @@
"android.permission.WRITE_SETTINGS" "android.permission.WRITE_SETTINGS"
], ],
"package": "com.nuvio.app", "package": "com.nuvio.app",
"versionCode": 31, "versionCode": 32,
"architectures": [ "architectures": [
"arm64-v8a", "arm64-v8a",
"armeabi-v7a", "armeabi-v7a",
@ -98,6 +98,6 @@
"fallbackToCacheTimeout": 30000, "fallbackToCacheTimeout": 30000,
"url": "https://ota.nuvioapp.space/api/manifest" "url": "https://ota.nuvioapp.space/api/manifest"
}, },
"runtimeVersion": "1.3.3" "runtimeVersion": "1.3.4"
} }
} }

View file

@ -30,6 +30,14 @@
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true" "https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
], ],
"versions": [ "versions": [
{
"version": "1.3.4",
"buildVersion": "32",
"date": "2026-01-06",
"localizedDescription": "## Update Notes\n\n### Player & Playback\n- Fixed **Android player crashes with large files** when using ExoPlayer \n - Merged PR **#361** by **@chrisk325**\n\n### Trakt Improvements\n- Improved **Trakt Continue Watching** section for better accuracy and reliability\n\n### Internationalization\n- Added **multi-language support** across the app using **i18n** \n - More languages will be added through **community contributions** \n - ⚠️ **Arabic UI does not use RTL yet**. RTL support will be added in a future update\n\n### Stability & Fixes\n- Crash optimizations and internal stability improvements\n\nThis update focuses on improving playback stability, Trakt experience, and expanding language support.",
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.4/app-release.apk",
"size": 25700000
},
{ {
"version": "1.3.3", "version": "1.3.3",
"buildVersion": "31", "buildVersion": "31",

100
package-lock.json generated
View file

@ -64,10 +64,14 @@
"expo-system-ui": "~6.0.7", "expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12", "expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.8",
"i18next": "^25.7.3",
"intl-pluralrules": "^2.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lottie-react-native": "~7.3.1", "lottie-react-native": "~7.3.1",
"posthog-react-native": "^4.4.0", "posthog-react-native": "^4.4.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.5.1",
"react-native": "0.81.4", "react-native": "0.81.4",
"react-native-boost": "^0.6.2", "react-native-boost": "^0.6.2",
"react-native-bottom-tabs": "^1.0.2", "react-native-bottom-tabs": "^1.0.2",
@ -87,7 +91,7 @@
"react-native-svg": "^15.12.1", "react-native-svg": "^15.12.1",
"react-native-url-polyfill": "^3.0.0", "react-native-url-polyfill": "^3.0.0",
"react-native-vector-icons": "^10.3.0", "react-native-vector-icons": "^10.3.0",
"react-native-video": "^6.17.0", "react-native-video": "6.18.0",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-wheel-color-picker": "^1.3.1", "react-native-wheel-color-picker": "^1.3.1",
"react-native-worklets": "^0.7.1" "react-native-worklets": "^0.7.1"
@ -7505,6 +7509,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/htmlparser2-without-node-native": { "node_modules/htmlparser2-without-node-native": {
"version": "3.9.2", "version": "3.9.2",
"resolved": "https://registry.npmjs.org/htmlparser2-without-node-native/-/htmlparser2-without-node-native-3.9.2.tgz", "resolved": "https://registry.npmjs.org/htmlparser2-without-node-native/-/htmlparser2-without-node-native-3.9.2.tgz",
@ -7616,6 +7629,37 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/i18next": {
"version": "25.7.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ieee754": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -7743,6 +7787,12 @@
"css-in-js-utils": "^3.1.0" "css-in-js-utils": "^3.1.0"
} }
}, },
"node_modules/intl-pluralrules": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/intl-pluralrules/-/intl-pluralrules-2.0.1.tgz",
"integrity": "sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg==",
"license": "ISC"
},
"node_modules/invariant": { "node_modules/invariant": {
"version": "2.2.4", "version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@ -10525,6 +10575,18 @@
} }
} }
}, },
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.0"
}
},
"node_modules/react-freeze": { "node_modules/react-freeze": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
@ -10537,6 +10599,33 @@
"react": ">=17.0.0" "react": ">=17.0.0"
} }
}, },
"node_modules/react-i18next": {
"version": "16.5.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz",
"integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
@ -13250,6 +13339,15 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/walker": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",

View file

@ -64,10 +64,14 @@
"expo-system-ui": "~6.0.7", "expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12", "expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8", "expo-web-browser": "~15.0.8",
"i18next": "^25.7.3",
"intl-pluralrules": "^2.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lottie-react-native": "~7.3.1", "lottie-react-native": "~7.3.1",
"posthog-react-native": "^4.4.0", "posthog-react-native": "^4.4.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.5.1",
"react-native": "0.81.4", "react-native": "0.81.4",
"react-native-boost": "^0.6.2", "react-native-boost": "^0.6.2",
"react-native-bottom-tabs": "^1.0.2", "react-native-bottom-tabs": "^1.0.2",
@ -87,7 +91,7 @@
"react-native-svg": "^15.12.1", "react-native-svg": "^15.12.1",
"react-native-url-polyfill": "^3.0.0", "react-native-url-polyfill": "^3.0.0",
"react-native-vector-icons": "^10.3.0", "react-native-vector-icons": "^10.3.0",
"react-native-video": "^6.17.0", "react-native-video": "6.18.0",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-wheel-color-picker": "^1.3.1", "react-native-wheel-color-picker": "^1.3.1",
"react-native-worklets": "^0.7.1" "react-native-worklets": "^0.7.1"

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,7 @@ import {
} from 'react-native'; } from 'react-native';
import { InteractionManager } from 'react-native'; import { InteractionManager } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns'; import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns';
import Animated, { FadeIn } from 'react-native-reanimated'; import Animated, { FadeIn } from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
@ -16,7 +17,6 @@ import { useTheme } from '../../contexts/ThemeContext';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const COLUMN_COUNT = 7; // 7 days in a week const COLUMN_COUNT = 7; // 7 days in a week
const DAY_ITEM_SIZE = (width - 32 - 56) / 7; // Slightly smaller than 1/7 to fit all days const DAY_ITEM_SIZE = (width - 32 - 56) / 7; // Slightly smaller than 1/7 to fit all days
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
interface CalendarEpisode { interface CalendarEpisode {
id: string; id: string;
@ -76,8 +76,19 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
episodes = [], episodes = [],
onSelectDate onSelectDate
}) => { }) => {
const { t } = useTranslation();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
const weekDays = [
t('common.days_short.sun'),
t('common.days_short.mon'),
t('common.days_short.tue'),
t('common.days_short.wed'),
t('common.days_short.thu'),
t('common.days_short.fri'),
t('common.days_short.sat')
];
const [selectedDate, setSelectedDate] = useState<Date | null>(null); const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
const [uiReady, setUiReady] = useState(false); const [uiReady, setUiReady] = useState(false);

View file

@ -12,6 +12,7 @@ import {
Image, Image,
} from 'react-native'; } from 'react-native';
import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native'; import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { RootStackParamList } from '../../navigation/AppNavigator'; import { RootStackParamList } from '../../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
@ -144,6 +145,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
onRetry, onRetry,
scrollY: externalScrollY, scrollY: externalScrollY,
}) => { }) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isFocused = useIsFocused(); const isFocused = useIsFocused();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
@ -158,7 +160,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const [inLibrary, setInLibrary] = useState(false); const [inLibrary, setInLibrary] = useState(false);
const [isInWatchlist, setIsInWatchlist] = useState(false); const [isInWatchlist, setIsInWatchlist] = useState(false);
const [isWatched, setIsWatched] = useState(false); const [isWatched, setIsWatched] = useState(false);
const [playButtonText, setPlayButtonText] = useState('Play'); const [shouldResume, setShouldResume] = useState(false);
const [type, setType] = useState<'movie' | 'series'>('movie'); const [type, setType] = useState<'movie' | 'series'>('movie');
// Create internal scrollY if not provided externally // Create internal scrollY if not provided externally
@ -530,7 +532,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
useEffect(() => { useEffect(() => {
if (currentItem) { if (currentItem) {
const buttonText = getProgressPlayButtonText(); const buttonText = getProgressPlayButtonText();
setPlayButtonText(buttonText); // Use internal state for resume logic instead of string comparison
setShouldResume(buttonText === 'Resume');
// Update watched state based on progress // Update watched state based on progress
if (watchProgress) { if (watchProgress) {
@ -987,10 +990,10 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
<View style={[styles.container, { height: HERO_HEIGHT, marginTop: -insets.top }]}> <View style={[styles.container, { height: HERO_HEIGHT, marginTop: -insets.top }]}>
<View style={styles.noContentContainer}> <View style={styles.noContentContainer}>
<MaterialIcons name="theaters" size={48} color="rgba(255,255,255,0.5)" /> <MaterialIcons name="theaters" size={48} color="rgba(255,255,255,0.5)" />
<Text style={styles.noContentText}>No featured content available</Text> <Text style={styles.noContentText}>{t('home.no_featured_available')}</Text>
{onRetry && ( {onRetry && (
<TouchableOpacity style={styles.retryButton} onPress={onRetry} activeOpacity={0.7}> <TouchableOpacity style={styles.retryButton} onPress={onRetry} activeOpacity={0.7}>
<Text style={styles.retryButtonText}>Retry</Text> <Text style={styles.retryButtonText}>{t('home.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
@ -1242,7 +1245,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
<View style={styles.metadataBadge}> <View style={styles.metadataBadge}>
<MaterialIcons name="tv" size={16} color="#fff" /> <MaterialIcons name="tv" size={16} color="#fff" />
<Text style={styles.metadataText}> <Text style={styles.metadataText}>
{currentItem.type === 'series' ? 'TV Show' : 'Movie'} {currentItem.type === 'series' ? t('home.tv_show') : t('home.movie')}
</Text> </Text>
{currentItem.genres && currentItem.genres.length > 0 && ( {currentItem.genres && currentItem.genres.length > 0 && (
<> <>
@ -1262,11 +1265,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
activeOpacity={0.85} activeOpacity={0.85}
> >
<MaterialIcons <MaterialIcons
name={playButtonText === 'Resume' ? "replay" : "play-arrow"} name={shouldResume ? "replay" : "play-arrow"}
size={24} size={24}
color="#000" color="#000"
/> />
<Text style={styles.playButtonText}>{playButtonText}</Text> <Text style={styles.playButtonText}>{shouldResume ? t('home.resume') : t('home.play')}</Text>
</TouchableOpacity> </TouchableOpacity>
{/* Save Button */} {/* Save Button */}

View file

@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useRef } from 'react'; import React, { useCallback, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions, FlatList } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions, FlatList } from 'react-native';
import { NavigationProp, useNavigation } from '@react-navigation/native'; import { NavigationProp, useNavigation } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { CatalogContent, StreamingContent } from '../../services/catalogService'; import { CatalogContent, StreamingContent } from '../../services/catalogService';
@ -8,6 +9,7 @@ import { useTheme } from '../../contexts/ThemeContext';
import ContentItem from './ContentItem'; import ContentItem from './ContentItem';
import Animated, { FadeIn, Layout } from 'react-native-reanimated'; import Animated, { FadeIn, Layout } from 'react-native-reanimated';
import { RootStackParamList } from '../../navigation/AppNavigator'; import { RootStackParamList } from '../../navigation/AppNavigator';
import { getFormattedCatalogName, getCatalogDisplayName } from '../../utils/catalogNameUtils';
interface CatalogSectionProps { interface CatalogSectionProps {
catalog: CatalogContent; catalog: CatalogContent;
@ -73,9 +75,44 @@ const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth; const POSTER_WIDTH = posterLayout.posterWidth;
const CatalogSection = ({ catalog }: CatalogSectionProps) => { const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const { t, i18n } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
// Use state for the display name to handle async custom name resolution
const [displayName, setDisplayName] = React.useState(catalog.name);
// Re-resolve and format the name when language or catalog data changes
React.useEffect(() => {
const resolveName = async () => {
// 1. Check for user-defined custom name
const customName = await getCatalogDisplayName(
catalog.addon,
catalog.type,
catalog.id,
catalog.originalName || catalog.name
);
// 2. If it's a user setting, use it as is
if (customName !== (catalog.originalName || catalog.name)) {
setDisplayName(customName);
return;
}
// 3. Otherwise, use localized formatting
const formatted = getFormattedCatalogName(
customName,
catalog.type,
t('home.movies'),
t('home.tv_shows'),
t('home.channels')
);
setDisplayName(formatted);
};
resolveName();
}, [catalog.addon, catalog.id, catalog.type, catalog.name, catalog.originalName, i18n.language, t]);
const handleContentPress = useCallback((id: string, type: string) => { const handleContentPress = useCallback((id: string, type: string) => {
navigation.navigate('Metadata', { id, type, addonId: catalog.addon }); navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
}, [navigation, catalog.addon]); }, [navigation, catalog.addon]);
@ -117,7 +154,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
]} ]}
numberOfLines={1} numberOfLines={1}
> >
{catalog.name} {displayName}
</Text> </Text>
<View <View
style={[ style={[
@ -154,7 +191,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14, fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
marginRight: isTV ? 6 : isLargeTablet ? 5 : 4, marginRight: isTV ? 6 : isLargeTablet ? 5 : 4,
} }
]}>View All</Text> ]}>{t('home.view_all')}</Text>
<MaterialIcons <MaterialIcons
name="chevron-right" name="chevron-right"
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20} size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { DeviceEventEmitter } from 'react-native'; import { DeviceEventEmitter } from 'react-native';
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share } from 'react-native'; import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share } from 'react-native';
@ -82,6 +83,7 @@ const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth; const POSTER_WIDTH = posterLayout.posterWidth;
const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, deferMs = 0 }: ContentItemProps) => { const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, deferMs = 0 }: ContentItemProps) => {
const { t } = useTranslation();
// Track inLibrary status locally to force re-render // Track inLibrary status locally to force re-render
const [inLibrary, setInLibrary] = useState(!!item.inLibrary); const [inLibrary, setInLibrary] = useState(!!item.inLibrary);
@ -182,10 +184,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
case 'library': case 'library':
if (inLibrary) { if (inLibrary) {
catalogService.removeFromLibrary(item.type, item.id); catalogService.removeFromLibrary(item.type, item.id);
showInfo('Removed from Library', 'Removed from your local library'); showInfo(t('library.removed_from_library'), t('library.item_removed'));
} else { } else {
catalogService.addToLibrary(item); catalogService.addToLibrary(item);
showSuccess('Added to Library', 'Added to your local library'); showSuccess(t('library.added_to_library'), t('library.item_added'));
} }
break; break;
case 'watched': { case 'watched': {
@ -194,7 +196,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
try { try {
await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false'); await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
} catch { } } catch { }
showInfo(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched', targetWatched ? 'Item marked as watched' : 'Item marked as unwatched'); showInfo(targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'), targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched'));
setTimeout(() => { setTimeout(() => {
DeviceEventEmitter.emit('watchedStatusChanged'); DeviceEventEmitter.emit('watchedStatusChanged');
}, 100); }, 100);
@ -240,10 +242,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
case 'trakt-watchlist': { case 'trakt-watchlist': {
if (isInWatchlist(item.id, item.type as 'movie' | 'show')) { if (isInWatchlist(item.id, item.type as 'movie' | 'show')) {
await removeFromWatchlist(item.id, item.type as 'movie' | 'show'); await removeFromWatchlist(item.id, item.type as 'movie' | 'show');
showInfo('Removed from Watchlist', 'Removed from your Trakt watchlist'); showInfo(t('library.removed_from_watchlist'), t('library.removed_from_watchlist_desc'));
} else { } else {
await addToWatchlist(item.id, item.type as 'movie' | 'show'); await addToWatchlist(item.id, item.type as 'movie' | 'show');
showSuccess('Added to Watchlist', 'Added to your Trakt watchlist'); showSuccess(t('library.added_to_watchlist'), t('library.added_to_watchlist_desc'));
} }
setMenuVisible(false); setMenuVisible(false);
break; break;
@ -251,10 +253,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
case 'trakt-collection': { case 'trakt-collection': {
if (isInCollection(item.id, item.type as 'movie' | 'show')) { if (isInCollection(item.id, item.type as 'movie' | 'show')) {
await removeFromCollection(item.id, item.type as 'movie' | 'show'); await removeFromCollection(item.id, item.type as 'movie' | 'show');
showInfo('Removed from Collection', 'Removed from your Trakt collection'); showInfo(t('library.removed_from_collection'), t('library.removed_from_collection_desc'));
} else { } else {
await addToCollection(item.id, item.type as 'movie' | 'show'); await addToCollection(item.id, item.type as 'movie' | 'show');
showSuccess('Added to Collection', 'Added to your Trakt collection'); showSuccess(t('library.added_to_collection'), t('library.added_to_collection_desc'));
} }
setMenuVisible(false); setMenuVisible(false);
break; break;

View file

@ -11,7 +11,12 @@ import {
Platform Platform
} from 'react-native'; } from 'react-native';
import { FlatList } from 'react-native'; import { FlatList } from 'react-native';
import { useTranslation } from 'react-i18next';
import Animated, { FadeIn, Layout } from 'react-native-reanimated'; import Animated, { FadeIn, Layout } from 'react-native-reanimated';
import BottomSheet, { BottomSheetModal, BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator'; import { RootStackParamList } from '../../navigation/AppNavigator';
@ -26,7 +31,7 @@ import { TraktService } from '../../services/traktService';
import { stremioService } from '../../services/stremioService'; import { stremioService } from '../../services/stremioService';
import { streamCacheService } from '../../services/streamCacheService'; import { streamCacheService } from '../../services/streamCacheService';
import { useSettings } from '../../hooks/useSettings'; import { useSettings } from '../../hooks/useSettings';
import CustomAlert from '../../components/CustomAlert';
// Define interface for continue watching items // Define interface for continue watching items
interface ContinueWatchingItem extends StreamingContent { interface ContinueWatchingItem extends StreamingContent {
@ -103,9 +108,11 @@ const isEpisodeReleased = (video: any): boolean => {
// Create a proper imperative handle with React.forwardRef and updated type // Create a proper imperative handle with React.forwardRef and updated type
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => { const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings(); const { settings } = useSettings();
const insets = useSafeAreaInsets();
const [continueWatchingItems, setContinueWatchingItems] = useState<ContinueWatchingItem[]>([]); const [continueWatchingItems, setContinueWatchingItems] = useState<ContinueWatchingItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const appState = useRef(AppState.currentState); const appState = useRef(AppState.currentState);
@ -113,6 +120,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const [deletingItemId, setDeletingItemId] = useState<string | null>(null); const [deletingItemId, setDeletingItemId] = useState<string | null>(null);
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null); const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Bottom sheet for item actions
const actionSheetRef = useRef<BottomSheetModal>(null);
const [selectedItem, setSelectedItem] = useState<ContinueWatchingItem | null>(null);
// Enhanced responsive sizing for tablets and TV screens // Enhanced responsive sizing for tablets and TV screens
const [dimensions, setDimensions] = useState(Dimensions.get('window')); const [dimensions, setDimensions] = useState(Dimensions.get('window'));
const deviceWidth = dimensions.width; const deviceWidth = dimensions.width;
@ -195,11 +206,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} }
}, [deviceType]); }, [deviceType]);
// Alert state for CustomAlert
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<any[]>([]);
// Use a ref to track if a background refresh is in progress to avoid state updates // Use a ref to track if a background refresh is in progress to avoid state updates
const isRefreshingRef = useRef(false); const isRefreshingRef = useRef(false);
@ -320,15 +327,21 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// 1. Filter items first (async checks) - do this BEFORE any state updates // 1. Filter items first (async checks) - do this BEFORE any state updates
const validItems: ContinueWatchingItem[] = []; const validItems: ContinueWatchingItem[] = [];
for (const it of batch) { for (const it of batch) {
const key = `${it.type}:${it.id}`; // For series, use episode-specific key
const key = it.type === 'series' && it.season && it.episode
? `${it.type}:${it.id}:${it.season}:${it.episode}`
: `${it.type}:${it.id}`;
// Skip recently removed items // Skip recently removed items
if (recentlyRemovedRef.current.has(key)) { if (recentlyRemovedRef.current.has(key)) {
continue; continue;
} }
// Skip persistently removed items // Skip persistently removed items (episode-specific for series)
const isRemoved = await storageService.isContinueWatchingRemoved(it.id, it.type); const removeId = it.type === 'series' && it.season && it.episode
? `${it.id}:${it.season}:${it.episode}`
: it.id;
const isRemoved = await storageService.isContinueWatchingRemoved(removeId, it.type);
if (isRemoved) { if (isRemoved) {
continue; continue;
} }
@ -511,8 +524,54 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const { episodeId, progress, progressPercent } = episode; const { episodeId, progress, progressPercent } = episode;
if (group.type === 'series' && progressPercent >= 85) { if (group.type === 'series' && progressPercent >= 85) {
// Skip completed episodes - don't add "next episode" here // Episode is completed - find the next unwatched episode
// The Trakt playback endpoint handles in-progress items let completedSeason: number | undefined;
let completedEpisode: number | undefined;
if (episodeId) {
const match = episodeId.match(/s(\d+)e(\d+)/i);
if (match) {
completedSeason = parseInt(match[1], 10);
completedEpisode = parseInt(match[2], 10);
} else {
const parts = episodeId.split(':');
if (parts.length >= 3) {
const seasonPart = parts[parts.length - 2];
const episodePart = parts[parts.length - 1];
const seasonNum = parseInt(seasonPart, 10);
const episodeNum = parseInt(episodePart, 10);
if (!isNaN(seasonNum) && !isNaN(episodeNum)) {
completedSeason = seasonNum;
completedEpisode = episodeNum;
}
}
}
}
// If we have valid season/episode info, find the next episode
if (completedSeason !== undefined && completedEpisode !== undefined && metadata?.videos) {
const watchedEpisodesSet = await traktShowsSetPromise;
const nextEpisode = findNextEpisode(
completedSeason,
completedEpisode,
metadata.videos,
watchedEpisodesSet,
group.id
);
if (nextEpisode) {
logger.log(`📺 [ContinueWatching] Found next episode: S${nextEpisode.season}E${nextEpisode.episode} for ${basicContent.name}`);
batch.push({
...basicContent,
progress: 0, // Up next - no progress yet
lastUpdated: progress.lastUpdated, // Keep the timestamp from completed episode
season: nextEpisode.season,
episode: nextEpisode.episode,
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
addonId: progress.addonId,
} as ContinueWatchingItem);
}
}
continue; continue;
} }
@ -627,13 +686,14 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
try { try {
// Skip items with < 2% progress (accidental clicks) // Skip items with < 2% progress (accidental clicks)
if (item.progress < 2) continue; if (item.progress < 2) continue;
// Skip items with >= 85% progress (completed)
if (item.progress >= 85) continue;
// Skip items older than 30 days // Skip items older than 30 days
const pausedAt = new Date(item.paused_at).getTime(); const pausedAt = new Date(item.paused_at).getTime();
if (pausedAt < thirtyDaysAgo) continue; if (pausedAt < thirtyDaysAgo) continue;
if (item.type === 'movie' && item.movie?.ids?.imdb) { if (item.type === 'movie' && item.movie?.ids?.imdb) {
// Skip completed movies
if (item.progress >= 85) continue;
const imdbId = item.movie.ids.imdb.startsWith('tt') const imdbId = item.movie.ids.imdb.startsWith('tt')
? item.movie.ids.imdb ? item.movie.ids.imdb
: `tt${item.movie.ids.imdb}`; : `tt${item.movie.ids.imdb}`;
@ -672,6 +732,37 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const cachedData = await getCachedMetadata('series', showImdb); const cachedData = await getCachedMetadata('series', showImdb);
if (!cachedData?.basicContent) continue; if (!cachedData?.basicContent) continue;
// If episode is completed (>= 85%), find next episode
if (item.progress >= 85) {
const metadata = cachedData.metadata;
if (metadata?.videos) {
const nextEpisode = findNextEpisode(
item.episode.season,
item.episode.number,
metadata.videos,
undefined, // No watched set needed, findNextEpisode handles it
showImdb
);
if (nextEpisode) {
logger.log(`📺 [TraktPlayback] Episode completed, adding next: S${nextEpisode.season}E${nextEpisode.episode} for ${item.show.title}`);
traktBatch.push({
...cachedData.basicContent,
id: showImdb,
type: 'series',
progress: 0, // Up next - no progress yet
lastUpdated: pausedAt,
season: nextEpisode.season,
episode: nextEpisode.episode,
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
addonId: undefined,
traktPlaybackId: item.id,
} as ContinueWatchingItem);
}
}
continue;
}
traktBatch.push({ traktBatch.push({
...cachedData.basicContent, ...cachedData.basicContent,
id: showImdb, id: showImdb,
@ -692,6 +783,93 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} }
} }
// STEP 2: Get watched shows and find "Up Next" episodes
// This handles cases where episodes are fully completed and removed from playback progress
try {
const watchedShows = await traktService.getWatchedShows();
const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000);
for (const watchedShow of watchedShows) {
try {
if (!watchedShow.show?.ids?.imdb) continue;
// Skip shows that haven't been watched recently
const lastWatchedAt = new Date(watchedShow.last_watched_at).getTime();
if (lastWatchedAt < thirtyDaysAgoForShows) continue;
const showImdb = watchedShow.show.ids.imdb.startsWith('tt')
? watchedShow.show.ids.imdb
: `tt${watchedShow.show.ids.imdb}`;
// Check if recently removed
const showKey = `series:${showImdb}`;
if (recentlyRemovedRef.current.has(showKey)) continue;
// Find the last watched episode
let lastWatchedSeason = 0;
let lastWatchedEpisode = 0;
let latestEpisodeTimestamp = 0;
if (watchedShow.seasons) {
for (const season of watchedShow.seasons) {
for (const episode of season.episodes) {
const episodeTimestamp = new Date(episode.last_watched_at).getTime();
if (episodeTimestamp > latestEpisodeTimestamp) {
latestEpisodeTimestamp = episodeTimestamp;
lastWatchedSeason = season.number;
lastWatchedEpisode = episode.number;
}
}
}
}
if (lastWatchedSeason === 0 && lastWatchedEpisode === 0) continue;
// Get metadata with episode list
const cachedData = await getCachedMetadata('series', showImdb);
if (!cachedData?.basicContent || !cachedData?.metadata?.videos) continue;
// Build a set of watched episodes for this show
const watchedEpisodeSet = new Set<string>();
if (watchedShow.seasons) {
for (const season of watchedShow.seasons) {
for (const episode of season.episodes) {
watchedEpisodeSet.add(`${showImdb}:${season.number}:${episode.number}`);
}
}
}
// Find the next unwatched episode
const nextEpisode = findNextEpisode(
lastWatchedSeason,
lastWatchedEpisode,
cachedData.metadata.videos,
watchedEpisodeSet,
showImdb
);
if (nextEpisode) {
logger.log(`📺 [TraktWatched] Found Up Next: ${watchedShow.show.title} S${nextEpisode.season}E${nextEpisode.episode}`);
traktBatch.push({
...cachedData.basicContent,
id: showImdb,
type: 'series',
progress: 0, // Up next - no progress yet
lastUpdated: latestEpisodeTimestamp,
season: nextEpisode.season,
episode: nextEpisode.episode,
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
addonId: undefined,
} as ContinueWatchingItem);
}
} catch (err) {
// Continue with other shows
}
}
} catch (err) {
logger.warn('[TraktSync] Error fetching watched shows for Up Next:', err);
}
// Set Trakt playback items as state (replace, don't merge with local storage) // Set Trakt playback items as state (replace, don't merge with local storage)
if (traktBatch.length > 0) { if (traktBatch.length > 0) {
// Dedupe: for series, keep only the latest episode per show // Dedupe: for series, keep only the latest episode per show
@ -704,9 +882,23 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} }
} }
const uniqueItems = Array.from(deduped.values()); const uniqueItems = Array.from(deduped.values());
logger.log(`📋 [TraktSync] Setting ${uniqueItems.length} items from Trakt playback (deduped from ${traktBatch.length})`);
// Filter out removed items
const filteredItems: ContinueWatchingItem[] = [];
for (const item of uniqueItems) {
// Check episode-specific removal for series
const removeId = item.type === 'series' && item.season && item.episode
? `${item.id}:${item.season}:${item.episode}`
: item.id;
const isRemoved = await storageService.isContinueWatchingRemoved(removeId, item.type);
if (!isRemoved) {
filteredItems.push(item);
}
}
logger.log(`📋 [TraktSync] Setting ${filteredItems.length} items from Trakt playback (deduped from ${traktBatch.length})`);
// Sort by lastUpdated descending and set directly // Sort by lastUpdated descending and set directly
const sortedBatch = uniqueItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); const sortedBatch = filteredItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
setContinueWatchingItems(sortedBatch); setContinueWatchingItems(sortedBatch);
} }
} catch (err) { } catch (err) {
@ -936,71 +1128,121 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} }
}, [navigation, settings.useCachedStreams, settings.openMetadataScreenWhenCacheDisabled]); }, [navigation, settings.useCachedStreams, settings.openMetadataScreenWhenCacheDisabled]);
// Handle long press to delete (moved before renderContinueWatchingItem) // Handle long press to show action sheet
const handleLongPress = useCallback(async (item: ContinueWatchingItem) => { const handleLongPress = useCallback((item: ContinueWatchingItem) => {
try { try {
// Trigger haptic feedback
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} catch (error) { } catch (error) {
// Ignore haptic errors // Ignore haptic errors
} }
setSelectedItem(item);
actionSheetRef.current?.present();
}, []);
const traktService = TraktService.getInstance(); // Handle view details action
const isAuthed = await traktService.isAuthenticated(); const handleViewDetails = useCallback(() => {
if (!selectedItem) return;
actionSheetRef.current?.dismiss();
setAlertTitle('Remove from Continue Watching'); setTimeout(() => {
if (selectedItem.type === 'series' && selectedItem.season && selectedItem.episode) {
const episodeId = `${selectedItem.id}:${selectedItem.season}:${selectedItem.episode}`;
navigation.navigate('Metadata', {
id: selectedItem.id,
type: selectedItem.type,
episodeId: episodeId,
addonId: selectedItem.addonId
});
} else {
navigation.navigate('Metadata', {
id: selectedItem.id,
type: selectedItem.type,
addonId: selectedItem.addonId
});
}
}, 150);
}, [selectedItem, navigation]);
if (isAuthed) { // Handle remove action
setAlertMessage(`Remove "${item.name}" from your continue watching list?\n\nThis will also remove it from your Trakt Continue Watching.`); const handleRemoveItem = useCallback(async () => {
} else { if (!selectedItem) return;
setAlertMessage(`Remove "${item.name}" from your continue watching list?`); actionSheetRef.current?.dismiss();
setDeletingItemId(selectedItem.id);
try {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// For series episodes, only remove the specific episode's local progress
// Don't add a base tombstone which would block all episodes of the series
const isEpisode = selectedItem.type === 'series' && selectedItem.season && selectedItem.episode;
if (isEpisode) {
// Only remove local progress for this specific episode (no base tombstone)
await storageService.removeAllWatchProgressForContent(
selectedItem.id,
selectedItem.type,
{ addBaseTombstone: false }
);
} else {
// For movies or whole series, add the base tombstone
await storageService.removeAllWatchProgressForContent(
selectedItem.id,
selectedItem.type,
{ addBaseTombstone: true }
);
}
const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated();
// Only remove playback progress from Trakt (not watch history)
// This ensures "Up Next" items don't affect Trakt watch history
if (isAuthed && selectedItem.traktPlaybackId) {
await traktService.removePlaybackItem(selectedItem.traktPlaybackId);
}
// For series, make the key episode-specific so dismissing "Up Next"
// doesn't affect other episodes
const itemKey = selectedItem.type === 'series' && selectedItem.season && selectedItem.episode
? `${selectedItem.type}:${selectedItem.id}:${selectedItem.season}:${selectedItem.episode}`
: `${selectedItem.type}:${selectedItem.id}`;
recentlyRemovedRef.current.add(itemKey);
// Store with episode-specific ID for series
const removeId = selectedItem.type === 'series' && selectedItem.season && selectedItem.episode
? `${selectedItem.id}:${selectedItem.season}:${selectedItem.episode}`
: selectedItem.id;
await storageService.addContinueWatchingRemoved(removeId, selectedItem.type);
setTimeout(() => {
recentlyRemovedRef.current.delete(itemKey);
}, REMOVAL_IGNORE_DURATION);
setContinueWatchingItems(prev => prev.filter(i => {
// For series, also check episode match
if (i.type === 'series' && selectedItem.type === 'series') {
return !(i.id === selectedItem.id && i.season === selectedItem.season && i.episode === selectedItem.episode);
}
return i.id !== selectedItem.id;
}));
} catch (error) {
// Continue even if removal fails
} finally {
setDeletingItemId(null);
setSelectedItem(null);
} }
}, [selectedItem]);
setAlertActions([ // Render backdrop for bottom sheet
{ const renderBackdrop = useCallback(
label: 'Cancel', (props: any) => (
style: { color: '#888' }, <BottomSheetBackdrop
onPress: () => { }, {...props}
}, disappearsOnIndex={-1}
{ appearsOnIndex={0}
label: 'Remove', opacity={0.6}
style: { color: currentTheme.colors.error }, />
onPress: async () => { ),
setDeletingItemId(item.id); []
try { );
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
await storageService.removeAllWatchProgressForContent(item.id, item.type, { addBaseTombstone: true });
if (isAuthed) {
let traktResult = false;
// If we have a playback ID (from sync/playback), use that to remove from Continue Watching
if (item.traktPlaybackId) {
traktResult = await traktService.removePlaybackItem(item.traktPlaybackId);
} else if (item.type === 'movie') {
traktResult = await traktService.removeMovieFromHistory(item.id);
} else if (item.type === 'series' && item.season !== undefined && item.episode !== undefined) {
traktResult = await traktService.removeEpisodeFromHistory(item.id, item.season, item.episode);
} else {
traktResult = await traktService.removeShowFromHistory(item.id);
}
}
const itemKey = `${item.type}:${item.id}`;
recentlyRemovedRef.current.add(itemKey);
await storageService.addContinueWatchingRemoved(item.id, item.type);
setTimeout(() => {
recentlyRemovedRef.current.delete(itemKey);
}, REMOVAL_IGNORE_DURATION);
setContinueWatchingItems(prev => prev.filter(i => i.id !== item.id));
} catch (error) {
// Continue even if removal fails
} finally {
setDeletingItemId(null);
}
},
},
]);
setAlertVisible(true);
}, [currentTheme.colors.error]);
// Compute poster dimensions for poster-style cards // Compute poster dimensions for poster-style cards
const computedPosterWidth = useMemo(() => { const computedPosterWidth = useMemo(() => {
@ -1070,7 +1312,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{/* Up Next Badge */} {/* Up Next Badge */}
{item.type === 'series' && item.progress === 0 && ( {item.type === 'series' && item.progress === 0 && (
<View style={[styles.posterUpNextBadge, { backgroundColor: currentTheme.colors.primary }]}> <View style={[styles.posterUpNextBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={[styles.posterUpNextText, { fontSize: isTV ? 12 : 10 }]}>UP NEXT</Text> <Text style={[styles.posterUpNextText, { fontSize: isTV ? 12 : 10 }]}>{t('home.up_next_caps')}</Text>
</View> </View>
)} )}
@ -1201,7 +1443,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
<Text style={[ <Text style={[
styles.progressText, styles.progressText,
{ fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 } { fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 }
]}>Up Next</Text> ]}>{t('home.up_next')}</Text>
</View> </View>
)} )}
</View> </View>
@ -1220,7 +1462,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13 fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
} }
]}> ]}>
Season {item.season} {t('home.season', { season: item.season })}
</Text> </Text>
{item.episodeTitle && ( {item.episodeTitle && (
<Text <Text
@ -1247,7 +1489,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13 fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
} }
]}> ]}>
{item.year} {item.type === 'movie' ? 'Movie' : 'Series'} {item.year} {item.type === 'movie' ? t('home.movie') : t('home.series')}
</Text> </Text>
); );
} }
@ -1279,7 +1521,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 11 fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 11
} }
]}> ]}>
{Math.round(item.progress)}% watched {t('home.percent_watched', { percent: Math.round(item.progress) })}
</Text> </Text>
</View> </View>
)} )}
@ -1318,7 +1560,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
color: currentTheme.colors.text, color: currentTheme.colors.text,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24 fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
} }
]}>Continue Watching</Text> ]}>{t('home.continue_watching')}</Text>
<View style={[ <View style={[
styles.titleUnderline, styles.titleUnderline,
{ {
@ -1349,13 +1591,101 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
removeClippedSubviews={true} removeClippedSubviews={true}
/> />
<CustomAlert {/* Action Sheet Bottom Sheet */}
visible={alertVisible} <BottomSheetModal
title={alertTitle} ref={actionSheetRef}
message={alertMessage} index={0}
actions={alertActions} snapPoints={['35%']}
onClose={() => setAlertVisible(false)} enablePanDownToClose={true}
/> backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
width: 40,
}}
onDismiss={() => {
setSelectedItem(null);
}}
>
<BottomSheetView style={[styles.actionSheetContent, { paddingBottom: insets.bottom + 16 }]}>
{selectedItem && (
<>
{/* Header with poster and info */}
<View style={styles.actionSheetHeader}>
<FastImage
source={{
uri: selectedItem.poster || 'https://via.placeholder.com/100x150',
priority: FastImage.priority.high,
}}
style={styles.actionSheetPoster}
resizeMode={FastImage.resizeMode.cover}
/>
<View style={styles.actionSheetInfo}>
<Text
style={[styles.actionSheetTitle, { color: currentTheme.colors.text }]}
numberOfLines={2}
>
{selectedItem.name}
</Text>
{selectedItem.type === 'series' && selectedItem.season && selectedItem.episode ? (
<Text style={[styles.actionSheetSubtitle, { color: currentTheme.colors.textMuted }]}>
{t('home.season', { season: selectedItem.season })} · {t('home.episode', { episode: selectedItem.episode })}
{selectedItem.episodeTitle && selectedItem.episodeTitle !== `Episode ${selectedItem.episode}` && `\n${selectedItem.episodeTitle}`}
</Text>
) : (
<Text style={[styles.actionSheetSubtitle, { color: currentTheme.colors.textMuted }]}>
{selectedItem.year ? `${selectedItem.type === 'movie' ? t('home.movie') : t('home.series')} · ${selectedItem.year}` : selectedItem.type === 'movie' ? t('home.movie') : t('home.series')}
</Text>
)}
{selectedItem.progress > 0 && (
<View style={styles.actionSheetProgressContainer}>
<View style={[styles.actionSheetProgressTrack, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View
style={[
styles.actionSheetProgressBar,
{
width: `${selectedItem.progress}%`,
backgroundColor: currentTheme.colors.primary
}
]}
/>
</View>
<Text style={[styles.actionSheetProgressText, { color: currentTheme.colors.textMuted }]}>
{t('home.percent_watched', { percent: Math.round(selectedItem.progress) })}
</Text>
</View>
)}
</View>
</View>
{/* Action Buttons */}
<View style={styles.actionSheetButtons}>
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleViewDetails}
activeOpacity={0.8}
>
<Ionicons name="information-circle-outline" size={22} color="#fff" />
<Text style={styles.actionButtonText}>{t('home.view_details')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionButton, styles.actionButtonSecondary, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={handleRemoveItem}
activeOpacity={0.8}
>
<Ionicons name="trash-outline" size={22} color={currentTheme.colors.error} />
<Text style={[styles.actionButtonText, { color: currentTheme.colors.error }]}>{t('home.remove')}</Text>
</TouchableOpacity>
</View>
</>
)}
</BottomSheetView>
</BottomSheetModal>
</View> </View>
); );
}); });
@ -1630,6 +1960,74 @@ const styles = StyleSheet.create({
fontWeight: '500', fontWeight: '500',
marginLeft: 6, marginLeft: 6,
}, },
// Action Sheet Styles
actionSheetContent: {
flex: 1,
paddingHorizontal: 20,
paddingTop: 8,
},
actionSheetHeader: {
flexDirection: 'row',
marginBottom: 20,
},
actionSheetPoster: {
width: 70,
height: 105,
borderRadius: 10,
marginRight: 16,
},
actionSheetInfo: {
flex: 1,
justifyContent: 'center',
},
actionSheetTitle: {
fontSize: 18,
fontWeight: '700',
marginBottom: 6,
lineHeight: 22,
},
actionSheetSubtitle: {
fontSize: 14,
opacity: 0.8,
lineHeight: 20,
},
actionSheetProgressContainer: {
marginTop: 10,
},
actionSheetProgressTrack: {
height: 4,
borderRadius: 2,
overflow: 'hidden',
},
actionSheetProgressBar: {
height: '100%',
borderRadius: 2,
},
actionSheetProgressText: {
fontSize: 12,
marginTop: 4,
},
actionSheetButtons: {
flexDirection: 'row',
gap: 12,
},
actionButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 14,
borderRadius: 14,
gap: 8,
},
actionButtonSecondary: {
borderWidth: 0,
},
actionButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
}); });
export default React.memo(ContinueWatchingSection, (prevProps, nextProps) => { export default React.memo(ContinueWatchingSection, (prevProps, nextProps) => {

View file

@ -10,6 +10,7 @@ import {
Dimensions, Dimensions,
Platform Platform
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { useTraktContext } from '../../contexts/TraktContext'; import { useTraktContext } from '../../contexts/TraktContext';
@ -39,6 +40,7 @@ interface DropUpMenuProps {
} }
export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: isSavedProp, isWatched: isWatchedProp }: DropUpMenuProps) => { export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: isSavedProp, isWatched: isWatchedProp }: DropUpMenuProps) => {
const { t } = useTranslation();
const translateY = useSharedValue(300); const translateY = useSharedValue(300);
const opacity = useSharedValue(0); const opacity = useSharedValue(0);
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
@ -102,12 +104,12 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
let menuOptions = [ let menuOptions = [
{ {
icon: 'bookmark', icon: 'bookmark',
label: isSaved ? 'Remove from Library' : 'Add to Library', label: isSaved ? t('library.remove_from_library') : t('library.add_to_library'),
action: 'library' action: 'library'
}, },
{ {
icon: 'check-circle', icon: 'check-circle',
label: isWatched ? 'Mark as Unwatched' : 'Mark as Watched', label: isWatched ? t('library.mark_unwatched') : t('library.mark_watched'),
action: 'watched' action: 'watched'
}, },
/* /*
@ -119,7 +121,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
*/ */
{ {
icon: 'share', icon: 'share',
label: 'Share', label: t('library.share'),
action: 'share' action: 'share'
} }
]; ];
@ -129,12 +131,12 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
menuOptions.push( menuOptions.push(
{ {
icon: 'playlist-add-check', icon: 'playlist-add-check',
label: inTraktWatchlist ? 'Remove from Trakt Watchlist' : 'Add to Trakt Watchlist', label: inTraktWatchlist ? t('library.remove_from_watchlist') : t('library.add_to_watchlist'),
action: 'trakt-watchlist' action: 'trakt-watchlist'
}, },
{ {
icon: 'video-library', icon: 'video-library',
label: inTraktCollection ? 'Remove from Trakt Collection' : 'Add to Trakt Collection', label: inTraktCollection ? t('library.remove_from_collection') : t('library.add_to_collection'),
action: 'trakt-collection' action: 'trakt-collection'
} }
); );

View file

@ -13,6 +13,7 @@ import {
Platform Platform
} from 'react-native'; } from 'react-native';
import { NavigationProp, useNavigation } from '@react-navigation/native'; import { NavigationProp, useNavigation } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { RootStackParamList } from '../../navigation/AppNavigator'; import { RootStackParamList } from '../../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
@ -52,6 +53,7 @@ const nowMs = () => Date.now();
const since = (start: number) => `${(nowMs() - start).toFixed(0)}ms`; const since = (start: number) => `${(nowMs() - start).toFixed(0)}ms`;
const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => { const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
@ -103,11 +105,11 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
return ( return (
<View style={styles.noContentContainer}> <View style={styles.noContentContainer}>
<MaterialIcons name="theaters" size={48} color={currentTheme.colors.mediumEmphasis} /> <MaterialIcons name="theaters" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={styles.noContentTitle}>{onRetry ? 'Couldn\'t load featured content' : 'No Featured Content'}</Text> <Text style={styles.noContentTitle}>{onRetry ? t('home.couldnt_load_featured') : t('home.no_featured_content')}</Text>
<Text style={styles.noContentText}> <Text style={styles.noContentText}>
{onRetry {onRetry
? 'There was a problem fetching featured content. Please check your connection and try again.' ? t('home.load_error_desc')
: 'Install addons with catalogs or change the content source in your settings.'} : t('home.no_featured_desc')}
</Text> </Text>
<View style={styles.noContentButtons}> <View style={styles.noContentButtons}>
{onRetry ? ( {onRetry ? (
@ -115,7 +117,7 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]} style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={onRetry} onPress={onRetry}
> >
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Retry</Text> <Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>{t('home.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<> <>
@ -123,13 +125,13 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]} style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Addons')} onPress={() => navigation.navigate('Addons')}
> >
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Install Addons</Text> <Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>{t('home.install_addons')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={styles.noContentButton} style={styles.noContentButton}
onPress={() => navigation.navigate('HomeScreenSettings')} onPress={() => navigation.navigate('HomeScreenSettings')}
> >
<Text style={styles.noContentButtonText}>Settings</Text> <Text style={styles.noContentButtonText}>{t('home.settings')}</Text>
</TouchableOpacity> </TouchableOpacity>
</> </>
)} )}
@ -139,6 +141,7 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
}; };
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loading, onRetry }: FeaturedContentProps) => { const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loading, onRetry }: FeaturedContentProps) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const [bannerUrl, setBannerUrl] = useState<string | null>(null); const [bannerUrl, setBannerUrl] = useState<string | null>(null);
@ -509,7 +512,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
> >
<MaterialIcons name="play-arrow" size={28} color={currentTheme.colors.black} /> <MaterialIcons name="play-arrow" size={28} color={currentTheme.colors.black} />
<Text style={[styles.tabletPlayButtonText as TextStyle, { color: currentTheme.colors.black }]}> <Text style={[styles.tabletPlayButtonText as TextStyle, { color: currentTheme.colors.black }]}>
Play Now {t('home.play_now')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -520,7 +523,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
> >
<MaterialIcons name={isSaved ? "bookmark" : "bookmark-outline"} size={20} color={currentTheme.colors.white} /> <MaterialIcons name={isSaved ? "bookmark" : "bookmark-outline"} size={20} color={currentTheme.colors.white} />
<Text style={[styles.tabletSecondaryButtonText as TextStyle, { color: currentTheme.colors.white }]}> <Text style={[styles.tabletSecondaryButtonText as TextStyle, { color: currentTheme.colors.white }]}>
{isSaved ? "Saved" : "My List"} {isSaved ? t('home.saved') : t('home.my_list')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -531,7 +534,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
> >
<MaterialIcons name="info-outline" size={20} color={currentTheme.colors.white} /> <MaterialIcons name="info-outline" size={20} color={currentTheme.colors.white} />
<Text style={[styles.tabletSecondaryButtonText as TextStyle, { color: currentTheme.colors.white }]}> <Text style={[styles.tabletSecondaryButtonText as TextStyle, { color: currentTheme.colors.white }]}>
More Info {t('home.more_info')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
@ -626,7 +629,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
> >
<MaterialIcons name={isSaved ? "bookmark" : "bookmark-outline"} size={24} color={currentTheme.colors.white} /> <MaterialIcons name={isSaved ? "bookmark" : "bookmark-outline"} size={24} color={currentTheme.colors.white} />
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}> <Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
{isSaved ? "Saved" : "Save"} {isSaved ? t('home.saved') : t('home.save')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -644,7 +647,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
> >
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} /> <MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}> <Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
Play {t('home.play')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -655,7 +658,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
> >
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} /> <MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}> <Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
Info {t('home.info')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>

View file

@ -1,5 +1,6 @@
import React, { useMemo, useState, useEffect, useCallback, memo, useRef } from 'react'; import React, { useMemo, useState, useEffect, useCallback, memo, useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, ScrollView, StyleProp, Platform, Image, useWindowDimensions } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, ScrollView, StyleProp, Platform, Image, useWindowDimensions } from 'react-native';
import { useTranslation } from 'react-i18next';
import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS, SharedValue, interpolate, Extrapolation } from 'react-native-reanimated'; import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS, SharedValue, interpolate, Extrapolation } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
@ -38,6 +39,7 @@ interface HeroCarouselProps {
const TOP_TABS_OFFSET = Platform.OS === 'ios' ? 44 : 48; const TOP_TABS_OFFSET = Platform.OS === 'ios' ? 44 : 48;
const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) => { const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
@ -610,6 +612,7 @@ interface CarouselCardProps {
} }
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip, interval, cardWidth, cardHeight, isTablet }) => { const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip, interval, cardWidth, cardHeight, isTablet }) => {
const { t } = useTranslation();
const [bannerLoaded, setBannerLoaded] = useState(false); const [bannerLoaded, setBannerLoaded] = useState(false);
const [logoLoaded, setLogoLoaded] = useState(false); const [logoLoaded, setLogoLoaded] = useState(false);
@ -847,7 +850,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
textShadowRadius: 2, textShadowRadius: 2,
} }
]}> ]}>
{item.description || 'No description available'} {item.description || t('home.no_description')}
</Text> </Text>
</ScrollView> </ScrollView>
</View> </View>
@ -956,7 +959,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
textShadowRadius: 2, textShadowRadius: 2,
} }
]}> ]}>
{item.description || 'No description available'} {item.description || t('home.no_description')}
</Text> </Text>
</ScrollView> </ScrollView>
</View> </View>

View file

@ -9,6 +9,7 @@ import {
Dimensions Dimensions
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
@ -58,6 +59,7 @@ interface ThisWeekEpisode {
} }
export const ThisWeekSection = React.memo(() => { export const ThisWeekSection = React.memo(() => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { calendarData, loading } = useCalendarData(); const { calendarData, loading } = useCalendarData();
@ -176,7 +178,7 @@ export const ThisWeekSection = React.memo(() => {
processedItems.push({ processedItems.push({
...firstEp, ...firstEp,
id: `group_${firstEp.seriesId}_${firstEp.releaseDate}`, // Unique ID for the group id: `group_${firstEp.seriesId}_${firstEp.releaseDate}`, // Unique ID for the group
title: `${group.length} New Episodes`, title: t('home.new_episodes', { count: group.length }),
isReleased, isReleased,
isGroup: true, isGroup: true,
episodeCount: group.length, episodeCount: group.length,
@ -239,7 +241,7 @@ export const ThisWeekSection = React.memo(() => {
const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => { const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => {
// Handle episodes without release dates gracefully // Handle episodes without release dates gracefully
const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null; const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null;
const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : 'TBA'; const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : t('home.tba');
const isReleased = item.isReleased; const isReleased = item.isReleased;
// Use episode still image if available, fallback to series poster // Use episode still image if available, fallback to series poster
@ -294,12 +296,12 @@ export const ThisWeekSection = React.memo(() => {
locations={[0, 0.4, 0.7, 1]} locations={[0, 0.4, 0.7, 1]}
> >
<View style={styles.cardHeader}> <View style={styles.cardHeader}>
<View style={[ <View style={[
styles.statusBadge, styles.statusBadge,
{ backgroundColor: isReleased ? currentTheme.colors.primary : 'rgba(0,0,0,0.6)' } { backgroundColor: isReleased ? currentTheme.colors.primary : 'rgba(0,0,0,0.6)' }
]}> ]}>
<Text style={styles.statusText}> <Text style={styles.statusText}>
{isReleased ? (item.isGroup ? 'Released' : 'New') : formattedDate} {isReleased ? (item.isGroup ? t('home.released') : t('home.new')) : formattedDate}
</Text> </Text>
</View> </View>
</View> </View>
@ -357,7 +359,7 @@ export const ThisWeekSection = React.memo(() => {
color: currentTheme.colors.text, color: currentTheme.colors.text,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24 fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
} }
]}>This Week</Text> ]}>{t('home.this_week')}</Text>
<View style={[ <View style={[
styles.titleUnderline, styles.titleUnderline,
{ {
@ -380,7 +382,7 @@ export const ThisWeekSection = React.memo(() => {
color: currentTheme.colors.textMuted, color: currentTheme.colors.textMuted,
fontSize: isTV ? 18 : isLargeTablet ? 16 : isTablet ? 15 : 14 fontSize: isTV ? 18 : isLargeTablet ? 16 : isTablet ? 15 : 14
} }
]}>View All</Text> ]}>{t('home.view_all')}</Text>
<MaterialIcons <MaterialIcons
name="chevron-right" name="chevron-right"
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20} size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
View, View,
Text, Text,
@ -70,6 +71,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
onClose, onClose,
castMember, castMember,
}) => { }) => {
const { t } = useTranslation();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const [personDetails, setPersonDetails] = useState<PersonDetails | null>(null); const [personDetails, setPersonDetails] = useState<PersonDetails | null>(null);
@ -82,14 +84,14 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
if (visible && castMember) { if (visible && castMember) {
modalOpacity.value = withTiming(1, { duration: 250 }); modalOpacity.value = withTiming(1, { duration: 250 });
modalScale.value = withSpring(1, { damping: 20, stiffness: 200 }); modalScale.value = withSpring(1, { damping: 20, stiffness: 200 });
if (!hasFetched || personDetails?.id !== castMember.id) { if (!hasFetched || personDetails?.id !== castMember.id) {
fetchPersonDetails(); fetchPersonDetails();
} }
} else { } else {
modalOpacity.value = withTiming(0, { duration: 200 }); modalOpacity.value = withTiming(0, { duration: 200 });
modalScale.value = withTiming(0.9, { duration: 200 }); modalScale.value = withTiming(0.9, { duration: 200 });
if (!visible) { if (!visible) {
setHasFetched(false); setHasFetched(false);
setPersonDetails(null); setPersonDetails(null);
@ -99,7 +101,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
const fetchPersonDetails = async () => { const fetchPersonDetails = async () => {
if (!castMember || loading) return; if (!castMember || loading) return;
setLoading(true); setLoading(true);
try { try {
const details = await tmdbService.getPersonDetails(castMember.id); const details = await tmdbService.getPersonDetails(castMember.id);
@ -150,11 +152,11 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
const birthDate = new Date(birthday); const birthDate = new Date(birthday);
let age = today.getFullYear() - birthDate.getFullYear(); let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth(); const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--; age--;
} }
return age; return age;
}; };
@ -196,8 +198,8 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
height: MODAL_HEIGHT, height: MODAL_HEIGHT,
overflow: 'hidden', overflow: 'hidden',
borderRadius: isTablet ? 32 : 24, borderRadius: isTablet ? 32 : 24,
backgroundColor: Platform.OS === 'android' backgroundColor: Platform.OS === 'android'
? 'rgba(20, 20, 20, 0.95)' ? 'rgba(20, 20, 20, 0.95)'
: 'transparent', : 'transparent',
}, },
modalStyle, modalStyle,
@ -280,7 +282,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
</View> </View>
)} )}
</View> </View>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={{ <Text style={{
color: '#fff', color: '#fff',
@ -296,7 +298,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
fontSize: isTablet ? 14 : 13, fontSize: isTablet ? 14 : 13,
fontWeight: '500', fontWeight: '500',
}} numberOfLines={2}> }} numberOfLines={2}>
as {castMember.character} {t('cast.as_character', { character: castMember.character })}
</Text> </Text>
)} )}
</View> </View>
@ -336,7 +338,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
fontSize: 14, fontSize: 14,
marginTop: 12, marginTop: 12,
}}> }}>
Loading details... {t('cast.loading_details')}
</Text> </Text>
</View> </View>
) : ( ) : (
@ -352,8 +354,8 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
borderColor: 'rgba(255, 255, 255, 0.06)', borderColor: 'rgba(255, 255, 255, 0.06)',
}}> }}>
{personDetails?.birthday && ( {personDetails?.birthday && (
<View style={{ <View style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: personDetails?.place_of_birth ? 10 : 0 marginBottom: personDetails?.place_of_birth ? 10 : 0
}}> }}>
@ -369,7 +371,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
fontSize: 13, fontSize: 13,
fontWeight: '500', fontWeight: '500',
}}> }}>
{calculateAge(personDetails.birthday)} years old {t('cast.years_old', { age: calculateAge(personDetails.birthday) })}
</Text> </Text>
</View> </View>
)} )}
@ -389,7 +391,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
fontWeight: '500', fontWeight: '500',
flex: 1, flex: 1,
}}> }}>
Born in {personDetails.place_of_birth} {t('cast.born_in', { place: personDetails.place_of_birth })}
</Text> </Text>
</View> </View>
)} )}
@ -420,7 +422,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
fontWeight: '600', fontWeight: '600',
letterSpacing: 0.3, letterSpacing: 0.3,
}}> }}>
View Filmography {t('cast.view_filmography')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -454,7 +456,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5, letterSpacing: 0.5,
}}> }}>
Also Known As {t('cast.also_known_as')}
</Text> </Text>
<Text style={{ <Text style={{
color: 'rgba(255, 255, 255, 0.7)', color: 'rgba(255, 255, 255, 0.7)',
@ -480,7 +482,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
textAlign: 'center', textAlign: 'center',
fontWeight: '500', fontWeight: '500',
}}> }}>
No additional information available {t('cast.no_info_available')}
</Text> </Text>
</View> </View>
)} )}

View file

@ -8,6 +8,7 @@ import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import Animated, { import Animated, {
FadeIn, FadeIn,
@ -35,6 +36,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
onSelectCastMember, onSelectCastMember,
isTmdbEnrichmentEnabled = true, isTmdbEnrichmentEnabled = true,
}) => { }) => {
const { t } = useTranslation();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
// Enhanced responsive sizing for tablets and TV screens // Enhanced responsive sizing for tablets and TV screens
@ -137,7 +139,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12 marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
} }
]}>Cast</Text> ]}>{t('metadata.cast')}</Text>
</View> </View>
<FlatList <FlatList
horizontal horizontal

View file

@ -8,6 +8,7 @@ import {
ActivityIndicator, ActivityIndicator,
Dimensions, Dimensions,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { useNavigation, StackActions } from '@react-navigation/native'; import { useNavigation, StackActions } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
@ -39,6 +40,7 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
collectionMovies, collectionMovies,
loadingCollection loadingCollection
}) => { }) => {
const { t } = useTranslation();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -109,9 +111,9 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
} }
} catch (error) { } catch (error) {
if (__DEV__) console.error('Error navigating to collection item:', error); if (__DEV__) console.error('Error navigating to collection item:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Unable to load this content. Please try again later.'); setAlertMessage(t('metadata.something_went_wrong'));
setAlertActions([{ label: 'OK', onPress: () => {} }]); setAlertActions([{ label: t('common.ok'), onPress: () => {} }]);
setAlertVisible(true); setAlertVisible(true);
} }
}; };

View file

@ -12,6 +12,7 @@ import {
Animated, Animated,
Linking, Linking,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import TraktIcon from '../../../assets/rating-icons/trakt.svg'; import TraktIcon from '../../../assets/rating-icons/trakt.svg';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
@ -186,6 +187,7 @@ const CompactCommentCard: React.FC<{
isSpoilerRevealed: boolean; isSpoilerRevealed: boolean;
onSpoilerPress: () => void; onSpoilerPress: () => void;
}> = ({ comment, theme, onPress, isSpoilerRevealed, onSpoilerPress }) => { }> = ({ comment, theme, onPress, isSpoilerRevealed, onSpoilerPress }) => {
const { t } = useTranslation();
const [isPressed, setIsPressed] = useState(false); const [isPressed, setIsPressed] = useState(false);
const fadeInOpacity = useRef(new Animated.Value(0)).current; const fadeInOpacity = useRef(new Animated.Value(0)).current;
@ -262,7 +264,7 @@ const CompactCommentCard: React.FC<{
// Handle missing user data gracefully // Handle missing user data gracefully
const user = comment.user || {}; const user = comment.user || {};
const username = user.name || user.username || 'Anonymous'; const username = user.name || user.username || t('common.anonymous_user');
// Handle spoiler content // Handle spoiler content
const hasSpoiler = comment.spoiler; const hasSpoiler = comment.spoiler;
@ -280,10 +282,10 @@ const CompactCommentCard: React.FC<{
const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'now'; if (diffMins < 1) return t('common.time.now');
if (diffMins < 60) return `${diffMins}m ago`; if (diffMins < 60) return t('common.time.minutes_ago', { count: diffMins });
if (diffHours < 24) return `${diffHours}h ago`; if (diffHours < 24) return t('common.time.hours_ago', { count: diffHours });
if (diffDays < 7) return `${diffDays}d ago`; if (diffDays < 7) return t('common.time.days_ago', { count: diffDays });
// For older dates, show month/day // For older dates, show month/day
return commentDate.toLocaleDateString('en-US', { return commentDate.toLocaleDateString('en-US', {
@ -725,6 +727,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
episode, episode,
onCommentPress, onCommentPress,
}) => { }) => {
const { t } = useTranslation();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings(); const { settings } = useSettings();
const [hasLoadedOnce, setHasLoadedOnce] = React.useState(false); const [hasLoadedOnce, setHasLoadedOnce] = React.useState(false);
@ -823,12 +826,12 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} /> <MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
{error ? 'Comments unavailable' : 'No comments on Trakt yet'} {error ? t('comments.unavailable') : t('comments.no_comments')}
</Text> </Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}> <Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
{error {error
? 'This content may not be in Trakt\'s database yet' ? t('comments.not_in_database')
: 'Be the first to comment on Trakt.tv' : t('comments.check_trakt')
} }
</Text> </Text>
</View> </View>
@ -930,7 +933,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20 fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
} }
]}> ]}>
Trakt Comments {t('comments.title')}
</Text> </Text>
</View> </View>
@ -945,7 +948,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
onPress={refresh} onPress={refresh}
> >
<Text style={[styles.retryButtonText, { color: currentTheme.colors.error }]}> <Text style={[styles.retryButtonText, { color: currentTheme.colors.error }]}>
Retry {t('common.retry')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -993,7 +996,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
) : ( ) : (
<> <>
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}> <Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
Load More {t('common.load_more')}
</Text> </Text>
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} /> <MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} />
</> </>

View file

@ -51,6 +51,7 @@ import { useToast } from '../../contexts/ToastContext';
import { useTraktContext } from '../../contexts/TraktContext'; import { useTraktContext } from '../../contexts/TraktContext';
import { useSettings } from '../../hooks/useSettings'; import { useSettings } from '../../hooks/useSettings';
import { useTrailer } from '../../contexts/TrailerContext'; import { useTrailer } from '../../contexts/TrailerContext';
import { useTranslation } from 'react-i18next';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { TMDBService } from '../../services/tmdbService'; import { TMDBService } from '../../services/tmdbService';
import TrailerService from '../../services/trailerService'; import TrailerService from '../../services/trailerService';
@ -149,6 +150,7 @@ const ActionButtons = memo(({
onToggleCollection?: () => void; onToggleCollection?: () => void;
}) => { }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast(); const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast();
// Performance optimization: Cache theme colors // Performance optimization: Cache theme colors
@ -235,9 +237,9 @@ const ActionButtons = memo(({
// Show appropriate toast // Show appropriate toast
if (wasInCollection) { if (wasInCollection) {
showInfo('Removed from Collection', 'Removed from your Trakt collection'); showInfo(t('metadata.removed_from_collection_hero'), t('metadata.removed_from_collection_desc_hero'));
} else { } else {
showSuccess('Added to Collection', 'Added to your Trakt collection'); showSuccess(t('metadata.added_to_collection_hero'), t('metadata.added_to_collection_desc_hero'));
} }
}, [onToggleCollection, isInCollection, showSuccess, showInfo]); }, [onToggleCollection, isInCollection, showSuccess, showInfo]);
@ -263,7 +265,7 @@ const ActionButtons = memo(({
const finalPlayButtonText = useMemo(() => { const finalPlayButtonText = useMemo(() => {
// For movies, handle watched state // For movies, handle watched state
if (type === 'movie') { if (type === 'movie') {
return isWatched ? 'Watch Again' : playButtonText; return isWatched ? t('metadata.watch_again') : playButtonText;
} }
// For series, validate next episode existence for both watched and resume cases // For series, validate next episode existence for both watched and resume cases
@ -306,7 +308,7 @@ const ActionButtons = memo(({
return `Play S${seasonStr}E${episodeStr}`; return `Play S${seasonStr}E${episodeStr}`;
} else { } else {
// If next episode doesn't exist, show generic text // If next episode doesn't exist, show generic text
return 'Completed'; return t('metadata.completed');
} }
} else { } else {
// For non-watched episodes, check if current episode exists // For non-watched episodes, check if current episode exists
@ -320,17 +322,17 @@ const ActionButtons = memo(({
return playButtonText; return playButtonText;
} else { } else {
// Current episode doesn't exist, fallback to generic play // Current episode doesn't exist, fallback to generic play
return 'Play'; return t('metadata.play');
} }
} }
} }
// Fallback label if parsing fails // Fallback label if parsing fails
return isWatched ? 'Play Next Episode' : playButtonText; return isWatched ? t('metadata.play_next_episode') : playButtonText;
} }
// Default fallback for non-series or missing data // Default fallback for non-series or missing data
return isWatched ? 'Play' : playButtonText; return isWatched ? t('metadata.play') : playButtonText;
}, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]); }, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]);
// Count additional buttons (excluding Play and Save) - AI Chat no longer counted // Count additional buttons (excluding Play and Save) - AI Chat no longer counted
@ -394,7 +396,7 @@ const ActionButtons = memo(({
color={inLibrary ? (isAuthenticated && isInWatchlist ? "#E74C3C" : currentTheme.colors.white) : currentTheme.colors.white} color={inLibrary ? (isAuthenticated && isInWatchlist ? "#E74C3C" : currentTheme.colors.white) : currentTheme.colors.white}
/> />
<Text style={[styles.infoButtonText, isTablet && styles.tabletInfoButtonText]}> <Text style={[styles.infoButtonText, isTablet && styles.tabletInfoButtonText]}>
{inLibrary ? 'Saved' : 'Save'} {inLibrary ? t('metadata.saved') : t('metadata.save')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -484,6 +486,7 @@ const WatchProgressDisplay = memo(({
trailerReady: boolean; trailerReady: boolean;
}) => { }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext(); const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext();
// State to trigger refresh after manual sync // State to trigger refresh after manual sync
@ -567,7 +570,7 @@ const WatchProgressDisplay = memo(({
progressPercent: 100, progressPercent: 100,
formattedTime: watchedDate, formattedTime: watchedDate,
episodeInfo, episodeInfo,
displayText: watchedViaTrakt ? 'Watched on Trakt' : 'Watched', displayText: watchedViaTrakt ? t('metadata.watched_on_trakt') : t('metadata.watched'),
syncStatus: isTraktAuthenticated && watchProgress?.traktSynced ? '' : '', // Clean look for watched syncStatus: isTraktAuthenticated && watchProgress?.traktSynced ? '' : '', // Clean look for watched
isTraktSynced: watchProgress?.traktSynced && isTraktAuthenticated, isTraktSynced: watchProgress?.traktSynced && isTraktAuthenticated,
isWatched: true isWatched: true
@ -597,22 +600,22 @@ const WatchProgressDisplay = memo(({
} }
// Enhanced display text with Trakt integration // Enhanced display text with Trakt integration
let displayText = progressPercent >= 85 ? 'Watched' : `${Math.round(progressPercent)}% watched`; let displayText = progressPercent >= 85 ? t('metadata.watched') : t('metadata.percent_watched', { percent: Math.round(progressPercent) });
let syncStatus = ''; let syncStatus = '';
// Show Trakt sync status if user is authenticated // Show Trakt sync status if user is authenticated
if (isTraktAuthenticated) { if (isTraktAuthenticated) {
if (isUsingTraktProgress) { if (isUsingTraktProgress) {
syncStatus = ' • Using Trakt progress'; syncStatus = ' • ' + t('metadata.using_trakt_progress');
if (watchProgress.traktSynced) { if (watchProgress.traktSynced) {
syncStatus = ' • Synced with Trakt'; syncStatus = ' • ' + t('metadata.synced_with_trakt_progress');
} }
} else if (watchProgress.traktSynced) { } else if (watchProgress.traktSynced) {
syncStatus = ' • Synced with Trakt'; syncStatus = ' • ' + t('metadata.synced_with_trakt_progress');
// If we have specific Trakt progress that differs from local, mention it // If we have specific Trakt progress that differs from local, mention it
if (watchProgress.traktProgress !== undefined && if (watchProgress.traktProgress !== undefined &&
Math.abs(progressPercent - watchProgress.traktProgress) > 5) { Math.abs(progressPercent - watchProgress.traktProgress) > 5) {
displayText = `${Math.round(progressPercent)}% watched (${Math.round(watchProgress.traktProgress)}% on Trakt)`; displayText = t('metadata.percent_watched_trakt', { percent: Math.round(progressPercent), traktPercent: Math.round(watchProgress.traktProgress) });
} }
} else { } else {
// Do not show "Sync pending" label anymore; leave status empty. // Do not show "Sync pending" label anymore; leave status empty.

View file

@ -9,6 +9,7 @@ import {
Dimensions, Dimensions,
Platform, Platform,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { useNavigation, StackActions } from '@react-navigation/native'; import { useNavigation, StackActions } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
@ -39,6 +40,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations, recommendations,
loadingRecommendations loadingRecommendations
}) => { }) => {
const { t } = useTranslation();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings(); const { settings } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -112,9 +114,9 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
} }
} catch (error) { } catch (error) {
if (__DEV__) console.error('Error navigating to recommendation:', error); if (__DEV__) console.error('Error navigating to recommendation:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Unable to load this content. Please try again later.'); setAlertMessage(t('metadata.something_went_wrong'));
setAlertActions([{ label: 'OK', onPress: () => { } }]); setAlertActions([{ label: t('common.ok'), onPress: () => { } }]);
setAlertVisible(true); setAlertVisible(true);
} }
}; };
@ -149,7 +151,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
return ( return (
<View style={[styles.container, { paddingLeft: 0 }]}> <View style={[styles.container, { paddingLeft: 0 }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>More Like This</Text> <Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>{t('metadata.more_like_this')}</Text>
<FlatList <FlatList
data={recommendations} data={recommendations}
renderItem={renderItem} renderItem={renderItem}

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react'; import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable } from 'react-native'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable } from 'react-native';
import { useTranslation } from 'react-i18next';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
@ -54,6 +55,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
}) => { }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
@ -740,7 +742,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
return ( return (
<View style={styles.centeredContainer}> <View style={styles.centeredContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} /> <ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>Loading episodes...</Text> <Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>{t('metadata.loading_episodes')}</Text>
</View> </View>
); );
} }
@ -749,7 +751,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
return ( return (
<View style={styles.centeredContainer}> <View style={styles.centeredContainer}>
<MaterialIcons name="error-outline" size={48} color={currentTheme.colors.textMuted} /> <MaterialIcons name="error-outline" size={48} color={currentTheme.colors.textMuted} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>No episodes available</Text> <Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>{t('metadata.no_episodes_available')}</Text>
</View> </View>
); );
} }
@ -785,7 +787,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
color: currentTheme.colors.highEmphasis, color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 18 fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 18
} }
]}>Seasons</Text> ]}>{t('metadata.seasons')}</Text>
{/* Dropdown Toggle Button */} {/* Dropdown Toggle Button */}
<TouchableOpacity <TouchableOpacity
@ -864,7 +866,6 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
styles.seasonTextButton, styles.seasonTextButton,
{ {
marginRight: seasonButtonSpacing, marginRight: seasonButtonSpacing,
width: isTV ? 150 : isLargeTablet ? 140 : isTablet ? 130 : 110,
paddingVertical: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12, paddingVertical: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
paddingHorizontal: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16, paddingHorizontal: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12 borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
@ -883,7 +884,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
{ color: currentTheme.colors.highEmphasis } { color: currentTheme.colors.highEmphasis }
] ]
]} numberOfLines={1}> ]} numberOfLines={1}>
{season === 0 ? 'Specials' : `Season ${season}`} {season === 0 ? t('metadata.specials') : t('metadata.season_number', { number: season })}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -946,7 +947,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
] ]
]} ]}
> >
{season === 0 ? 'Specials' : `Season ${season}`} {season === 0 ? t('metadata.specials') : t('metadata.season_number', { number: season })}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -1557,7 +1558,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
paddingHorizontal: horizontalPadding paddingHorizontal: horizontalPadding
} }
]}> ]}>
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'} {currentSeasonEpisodes.length === 1 ? t('metadata.episode_count', { count: currentSeasonEpisodes.length }) : t('metadata.episode_count_plural', { count: currentSeasonEpisodes.length })}
</Text> </Text>
{/* Show message when no episodes are available for selected season */} {/* Show message when no episodes are available for selected season */}
@ -1565,10 +1566,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
<View style={styles.centeredContainer}> <View style={styles.centeredContainer}>
<MaterialIcons name="schedule" size={48} color={currentTheme.colors.textMuted} /> <MaterialIcons name="schedule" size={48} color={currentTheme.colors.textMuted} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}> <Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>
No episodes available for Season {selectedSeason} {t('metadata.no_episodes_for_season', { season: selectedSeason })}
</Text> </Text>
<Text style={[styles.centeredSubText, { color: currentTheme.colors.textMuted }]}> <Text style={[styles.centeredSubText, { color: currentTheme.colors.textMuted }]}>
Episodes may not be released yet {t('metadata.episodes_not_released')}
</Text> </Text>
</View> </View>
)} )}
@ -1748,7 +1749,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
fontSize: isTV ? 16 : 15, fontSize: isTV ? 16 : 15,
fontWeight: '500', fontWeight: '500',
}}> }}>
{markingAsWatched ? 'Removing...' : 'Mark as Unwatched'} {markingAsWatched ? t('metadata.removing') : t('metadata.mark_as_unwatched')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
@ -1775,7 +1776,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
fontSize: isTV ? 16 : 15, fontSize: isTV ? 16 : 15,
fontWeight: '600', fontWeight: '600',
}}> }}>
{markingAsWatched ? 'Marking...' : 'Mark as Watched'} {markingAsWatched ? t('metadata.marking') : t('metadata.mark_as_watched')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) )
@ -1807,7 +1808,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
fontWeight: '500', fontWeight: '500',
flex: 1, // Allow text to take up space flex: 1, // Allow text to take up space
}} numberOfLines={1}> }} numberOfLines={1}>
{markingAsWatched ? 'Removing...' : `Unmark Season ${selectedSeason}`} {markingAsWatched ? t('metadata.removing') : t('metadata.unmark_season', { season: selectedSeason })}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
@ -1835,7 +1836,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
fontWeight: '500', fontWeight: '500',
flex: 1, flex: 1,
}} numberOfLines={1}> }} numberOfLines={1}>
{markingAsWatched ? 'Marking...' : `Mark Season ${selectedSeason}`} {markingAsWatched ? t('metadata.marking') : t('metadata.mark_season', { season: selectedSeason })}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@ -1854,7 +1855,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
fontSize: isTV ? 15 : 14, fontSize: isTV ? 15 : 14,
fontWeight: '500', fontWeight: '500',
}}> }}>
Cancel {t('common.cancel')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View file

@ -10,6 +10,7 @@ import {
Platform, Platform,
Alert, Alert,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { useTrailer } from '../../contexts/TrailerContext'; import { useTrailer } from '../../contexts/TrailerContext';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
@ -19,24 +20,6 @@ import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video'
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
const isTablet = width >= 768; const isTablet = width >= 768;
// Helper function to format trailer type
const formatTrailerType = (type: string): string => {
switch (type) {
case 'Trailer':
return 'Official Trailer';
case 'Teaser':
return 'Teaser';
case 'Clip':
return 'Clip';
case 'Featurette':
return 'Featurette';
case 'Behind the Scenes':
return 'Behind the Scenes';
default:
return type;
}
};
interface TrailerVideo { interface TrailerVideo {
id: string; id: string;
key: string; key: string;
@ -61,8 +44,28 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
trailer, trailer,
contentTitle contentTitle
}) => { }) => {
const { t } = useTranslation();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { pauseTrailer, resumeTrailer } = useTrailer(); const { pauseTrailer, resumeTrailer } = useTrailer();
// Helper function to format trailer type with translations
const formatTrailerType = useCallback((type: string): string => {
switch (type) {
case 'Trailer':
return t('trailers.official_trailer');
case 'Teaser':
return t('trailers.teaser');
case 'Clip':
return t('trailers.clip');
case 'Featurette':
return t('trailers.featurette');
case 'Behind the Scenes':
return t('trailers.behind_the_scenes');
default:
return type;
}
}, [t]);
const videoRef = React.useRef<VideoRef>(null); const videoRef = React.useRef<VideoRef>(null);
const [trailerUrl, setTrailerUrl] = useState<string | null>(null); const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -126,9 +129,9 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
logger.error('TrailerModal', 'Error loading trailer:', err); logger.error('TrailerModal', 'Error loading trailer:', err);
Alert.alert( Alert.alert(
'Trailer Unavailable', t('trailers.unavailable'),
'This trailer could not be loaded at this time. Please try again later.', t('trailers.unavailable_desc'),
[{ text: 'OK', style: 'default' }] [{ text: t('common.ok'), style: 'default' }]
); );
} }
}, [trailer, contentTitle, pauseTrailer]); }, [trailer, contentTitle, pauseTrailer]);
@ -232,7 +235,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
hitSlop={{ top: 10, left: 10, right: 10, bottom: 10 }} hitSlop={{ top: 10, left: 10, right: 10, bottom: 10 }}
> >
<Text style={[styles.closeButtonText, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.closeButtonText, { color: currentTheme.colors.highEmphasis }]}>
Close {t('common.close')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -257,7 +260,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]} style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={loadTrailer} onPress={loadTrailer}
> >
<Text style={styles.retryButtonText}>Try Again</Text> <Text style={styles.retryButtonText}>{t('common.try_again')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}

View file

@ -11,6 +11,7 @@ import {
ScrollView, ScrollView,
Modal, Modal,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
@ -59,6 +60,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
contentId, contentId,
contentTitle contentTitle
}) => { }) => {
const { t } = useTranslation();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings(); const { settings } = useSettings();
const { pauseTrailer } = useTrailer(); const { pauseTrailer } = useTrailer();
@ -414,22 +416,22 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
}; };
// Format trailer type for display // Format trailer type for display
const formatTrailerType = (type: string): string => { const formatTrailerType = useCallback((type: string): string => {
switch (type) { switch (type) {
case 'Trailer': case 'Trailer':
return 'Official Trailers'; return t('trailers.official_trailers');
case 'Teaser': case 'Teaser':
return 'Teasers'; return t('trailers.teasers');
case 'Clip': case 'Clip':
return 'Clips & Scenes'; return t('trailers.clips_scenes');
case 'Featurette': case 'Featurette':
return 'Featurettes'; return t('trailers.featurettes');
case 'Behind the Scenes': case 'Behind the Scenes':
return 'Behind the Scenes'; return t('trailers.behind_the_scenes');
default: default:
return type; return type;
} }
}; }, [t]);
// Get icon for trailer type // Get icon for trailer type
const getTrailerTypeIcon = (type: string): string => { const getTrailerTypeIcon = (type: string): string => {
@ -483,12 +485,12 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
<View style={styles.header}> <View style={styles.header}>
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} /> <MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
Trailers {t('trailers.title')}
</Text> </Text>
</View> </View>
<View style={styles.noTrailersContainer}> <View style={styles.noTrailersContainer}>
<Text style={[styles.noTrailersText, { color: currentTheme.colors.textMuted }]}> <Text style={[styles.noTrailersText, { color: currentTheme.colors.textMuted }]}>
No trailers available {t('trailers.no_trailers')}
</Text> </Text>
</View> </View>
</View> </View>
@ -512,7 +514,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20 fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
} }
]}> ]}>
Trailers & Videos {t('trailers.title')}
</Text> </Text>
{/* Category Selector - Right Aligned */} {/* Category Selector - Right Aligned */}

View file

@ -4,6 +4,7 @@ import { Ionicons } from '@expo/vector-icons';
import Feather from 'react-native-vector-icons/Feather'; import Feather from 'react-native-vector-icons/Feather';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import Slider from '@react-native-community/slider'; import Slider from '@react-native-community/slider';
import { useTranslation } from 'react-i18next';
import { styles } from '../utils/playerStyles'; // Updated styles import { styles } from '../utils/playerStyles'; // Updated styles
import { getTrackDisplayName } from '../utils/playerUtils'; import { getTrackDisplayName } from '../utils/playerUtils';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
@ -99,6 +100,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
useExoPlayer, useExoPlayer,
}) => { }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
/* Responsive Spacing */ /* Responsive Spacing */
@ -287,7 +289,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
}} }}
minimumValue={0} minimumValue={0}
maximumValue={duration || 1} maximumValue={duration || 1}
value={previewTime} value={previewTime}
onValueChange={(v) => setPreviewTime(v)} onValueChange={(v) => setPreviewTime(v)}
@ -338,7 +340,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
{/* Show year and provider (quality chip removed) */} {/* Show year and provider (quality chip removed) */}
<View style={styles.metadataRow}> <View style={styles.metadataRow}>
{year && <Text style={styles.metadataText}>{year}</Text>} {year && <Text style={styles.metadataText}>{year}</Text>}
{streamName && <Text style={styles.providerText}>via {streamName}</Text>} {streamName && <Text style={styles.providerText}>{t('player_ui.via', { name: streamName })}</Text>}
</View> </View>
{playerBackend && ( {playerBackend && (
<View style={styles.metadataRow}> <View style={styles.metadataRow}>

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, useWindowDimensions, StyleSheet, Platform } from 'react-native'; import { View, Text, TouchableOpacity, ScrollView, useWindowDimensions, StyleSheet, Platform } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Animated, { import Animated, {
FadeIn, FadeIn,
FadeOut, FadeOut,
@ -25,6 +26,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
selectedAudioTrack, selectedAudioTrack,
selectAudioTrack, selectAudioTrack,
}) => { }) => {
const { t } = useTranslation();
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
// Size constants matching SubtitleModal aesthetics // Size constants matching SubtitleModal aesthetics
@ -67,7 +69,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
> >
{/* Header with shared aesthetics */} {/* Header with shared aesthetics */}
<View style={{ flexDirection: 'row', alignItems: 'center', padding: 20, position: 'relative' }}> <View style={{ flexDirection: 'row', alignItems: 'center', padding: 20, position: 'relative' }}>
<Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>Audio Tracks</Text> <Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>{t('player_ui.audio_tracks')}</Text>
</View> </View>
<ScrollView <ScrollView
@ -111,7 +113,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
{ksAudioTracks.length === 0 && ( {ksAudioTracks.length === 0 && (
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}> <View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<MaterialIcons name="volume-off" size={32} color="white" /> <MaterialIcons name="volume-off" size={32} color="white" />
<Text style={{ color: 'white', marginTop: 10 }}>No audio tracks available</Text> <Text style={{ color: 'white', marginTop: 10 }}>{t('player_ui.no_audio_tracks')}</Text>
</View> </View>
)} )}
</View> </View>

View file

@ -7,6 +7,7 @@ import Animated, {
SlideInRight, SlideInRight,
SlideOutRight, SlideOutRight,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { Episode } from '../../../types/metadata'; import { Episode } from '../../../types/metadata';
import { Stream } from '../../../types/streams'; import { Stream } from '../../../types/streams';
import { stremioService } from '../../../services/stremioService'; import { stremioService } from '../../../services/stremioService';
@ -58,6 +59,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
onSelectStream, onSelectStream,
metadata, metadata,
}) => { }) => {
const { t } = useTranslation();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const MENU_WIDTH = Math.min(width * 0.85, 400); const MENU_WIDTH = Math.min(width * 0.85, 400);
@ -177,7 +179,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<View style={{ flex: 1, marginRight: 10 }}> <View style={{ flex: 1, marginRight: 10 }}>
<Text style={{ color: 'white', fontSize: 20, fontWeight: '700' }} numberOfLines={1}> <Text style={{ color: 'white', fontSize: 20, fontWeight: '700' }} numberOfLines={1}>
{episode?.name || 'Sources'} {episode?.name || t('player_ui.sources')}
</Text> </Text>
{episode && ( {episode && (
<Text style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, marginTop: 4 }}> <Text style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, marginTop: 4 }}>
@ -195,7 +197,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
{isLoading && sortedProviders.length === 0 && ( {isLoading && sortedProviders.length === 0 && (
<View style={{ padding: 40, alignItems: 'center' }}> <View style={{ padding: 40, alignItems: 'center' }}>
<ActivityIndicator color="white" /> <ActivityIndicator color="white" />
<Text style={{ color: 'white', marginTop: 15, opacity: 0.6 }}>Finding sources...</Text> <Text style={{ color: 'white', marginTop: 15, opacity: 0.6 }}>{t('player_ui.finding_sources')}</Text>
</View> </View>
)} )}
@ -237,7 +239,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
<Text style={{ color: 'white', fontWeight: '700', fontSize: 14, flex: 1 }} numberOfLines={1}> <Text style={{ color: 'white', fontWeight: '700', fontSize: 14, flex: 1 }} numberOfLines={1}>
{stream.name || 'Unknown Source'} {stream.name || t('player_ui.unknown_source')}
</Text> </Text>
<QualityBadge quality={quality} /> <QualityBadge quality={quality} />
</View> </View>
@ -258,13 +260,13 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
{!isLoading && sortedProviders.length === 0 && ( {!isLoading && sortedProviders.length === 0 && (
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}> <View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<MaterialIcons name="cloud-off" size={48} color="white" /> <MaterialIcons name="cloud-off" size={48} color="white" />
<Text style={{ color: 'white', marginTop: 16, textAlign: 'center', fontWeight: '600' }}>No sources found</Text> <Text style={{ color: 'white', marginTop: 16, textAlign: 'center', fontWeight: '600' }}>{t('player_ui.no_sources_found')}</Text>
</View> </View>
)} )}
{hasErrors.length > 0 && ( {hasErrors.length > 0 && (
<View style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: 12, padding: 12, marginTop: 10 }}> <View style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: 12, padding: 12, marginTop: 10 }}>
<Text style={{ color: '#EF4444', fontSize: 11 }}>Sources might be limited due to provider errors.</Text> <Text style={{ color: '#EF4444', fontSize: 11 }}>{t('player_ui.sources_limited')}</Text>
</View> </View>
)} )}
</ScrollView> </ScrollView>

View file

@ -7,6 +7,7 @@ import Animated, {
SlideInRight, SlideInRight,
SlideOutRight, SlideOutRight,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { Episode } from '../../../types/metadata'; import { Episode } from '../../../types/metadata';
import { EpisodeCard } from '../cards/EpisodeCard'; import { EpisodeCard } from '../cards/EpisodeCard';
import { storageService } from '../../../services/storageService'; import { storageService } from '../../../services/storageService';
@ -32,6 +33,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
onSelectEpisode, onSelectEpisode,
tmdbEpisodeOverrides tmdbEpisodeOverrides
}) => { }) => {
const { t } = useTranslation();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const [selectedSeason, setSelectedSeason] = useState<number>(currentEpisode?.season || 1); const [selectedSeason, setSelectedSeason] = useState<number>(currentEpisode?.season || 1);
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: any }>({}); const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: any }>({});
@ -117,7 +119,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
> >
<View style={{ paddingTop: Platform.OS === 'ios' ? 60 : 20, paddingHorizontal: 20 }}> <View style={{ paddingTop: Platform.OS === 'ios' ? 60 : 20, paddingHorizontal: 20 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }}>Episodes</Text> <Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }}>{t('player_ui.episodes')}</Text>
</View> </View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 15, gap: 8 }}> <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 15, gap: 8 }}>
@ -143,7 +145,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
color: selectedSeason === season ? 'black' : 'white', color: selectedSeason === season ? 'black' : 'white',
fontWeight: selectedSeason === season ? '700' : '500' fontWeight: selectedSeason === season ? '700' : '500'
}}> }}>
{season === 0 ? 'Specials' : `Season ${season}`} {season === 0 ? t('player_ui.specials') : t('player_ui.season', { season })}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}

View file

@ -2,6 +2,7 @@ import React from 'react';
import * as ExpoClipboard from 'expo-clipboard'; import * as ExpoClipboard from 'expo-clipboard';
import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Animated, { import Animated, {
FadeIn, FadeIn,
FadeOut, FadeOut,
@ -22,6 +23,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
errorDetails, errorDetails,
onDismiss, onDismiss,
}) => { }) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const MODAL_WIDTH = Math.min(width * 0.8, 400); const MODAL_WIDTH = Math.min(width * 0.8, 400);
@ -79,7 +81,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
marginBottom: 8, marginBottom: 8,
textAlign: 'center' textAlign: 'center'
}}> }}>
Playback Error {t('player_ui.playback_error')}
</Text> </Text>
<Text <Text
@ -93,7 +95,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
lineHeight: 22 lineHeight: 22
}} }}
> >
{errorDetails || 'An unknown error occurred during playback.'} {errorDetails || t('player_ui.unknown_error')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
@ -114,7 +116,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
style={{ marginRight: 6 }} style={{ marginRight: 6 }}
/> />
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 13, fontWeight: '500' }}> <Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 13, fontWeight: '500' }}>
{copied ? 'Copied to clipboard' : 'Copy error details'} {copied ? t('player_ui.copied_to_clipboard') : t('player_ui.copy_error')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -135,7 +137,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
fontSize: 16, fontSize: 16,
fontWeight: '700' fontWeight: '700'
}}> }}>
Dismiss {t('player_ui.dismiss')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>

View file

@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import { View, Text, TouchableOpacity } from 'react-native'; import { View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useTranslation } from 'react-i18next';
import { styles } from '../utils/playerStyles'; import { styles } from '../utils/playerStyles';
import { formatTime } from '../utils/playerUtils'; import { formatTime } from '../utils/playerUtils';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
@ -27,6 +28,7 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
handleResume, handleResume,
handleStartFromBeginning, handleStartFromBeginning,
}) => { }) => {
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
// Removed excessive logging for props changes // Removed excessive logging for props changes
}, [showResumeOverlay, resumePosition, duration, title]); }, [showResumeOverlay, resumePosition, duration, title]);
@ -35,9 +37,9 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
// Removed excessive logging for overlay visibility // Removed excessive logging for overlay visibility
return null; return null;
} }
// Removed excessive logging for overlay rendering // Removed excessive logging for overlay rendering
return ( return (
<View style={styles.resumeOverlay}> <View style={styles.resumeOverlay}>
<LinearGradient <LinearGradient
@ -49,18 +51,18 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
<Ionicons name="play-circle" size={40} color="#E50914" /> <Ionicons name="play-circle" size={40} color="#E50914" />
</View> </View>
<View style={styles.resumeTextContainer}> <View style={styles.resumeTextContainer}>
<Text style={styles.resumeTitle}>Continue Watching</Text> <Text style={styles.resumeTitle}>{t('player_ui.continue_watching')}</Text>
<Text style={styles.resumeInfo}> <Text style={styles.resumeInfo}>
{title} {title}
{season && episode && ` • S${season}E${episode}`} {season && episode && ` • S${season}E${episode}`}
</Text> </Text>
<View style={styles.resumeProgressContainer}> <View style={styles.resumeProgressContainer}>
<View style={styles.resumeProgressBar}> <View style={styles.resumeProgressBar}>
<View <View
style={[ style={[
styles.resumeProgressFill, styles.resumeProgressFill,
{ width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` } { width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` }
]} ]}
/> />
</View> </View>
<Text style={styles.resumeTimeText}> <Text style={styles.resumeTimeText}>
@ -71,19 +73,19 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
</View> </View>
<View style={styles.resumeButtons}> <View style={styles.resumeButtons}>
<TouchableOpacity <TouchableOpacity
style={styles.resumeButton} style={styles.resumeButton}
onPress={handleStartFromBeginning} onPress={handleStartFromBeginning}
> >
<Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} /> <Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} />
<Text style={styles.resumeButtonText}>Start Over</Text> <Text style={styles.resumeButtonText}>{t('player_ui.start_over')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.resumeButton, styles.resumeFromButton]} style={[styles.resumeButton, styles.resumeFromButton]}
onPress={handleResume} onPress={handleResume}
> >
<Ionicons name="play" size={16} color="white" style={styles.buttonIcon} /> <Ionicons name="play" size={16} color="white" style={styles.buttonIcon} />
<Text style={styles.resumeButtonText}>Resume</Text> <Text style={styles.resumeButtonText}>{t('player_ui.resume')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</LinearGradient> </LinearGradient>

View file

@ -7,6 +7,7 @@ import Animated, {
SlideInRight, SlideInRight,
SlideOutRight, SlideOutRight,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { Stream } from '../../../types/streams'; import { Stream } from '../../../types/streams';
interface SourcesModalProps { interface SourcesModalProps {
@ -57,6 +58,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
onSelectStream, onSelectStream,
isChangingSource = false, isChangingSource = false,
}) => { }) => {
const { t } = useTranslation();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const MENU_WIDTH = Math.min(width * 0.85, 400); const MENU_WIDTH = Math.min(width * 0.85, 400);
@ -123,7 +125,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
alignItems: 'center' alignItems: 'center'
}}> }}>
<Text style={{ color: 'white', fontSize: 20, fontWeight: '700' }}> <Text style={{ color: 'white', fontSize: 20, fontWeight: '700' }}>
Change Source {t('player_ui.change_source')}
</Text> </Text>
</View> </View>
@ -142,7 +144,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
}}> }}>
<ActivityIndicator size="small" color="#22C55E" /> <ActivityIndicator size="small" color="#22C55E" />
<Text style={{ color: '#22C55E', fontSize: 14, fontWeight: '600', marginLeft: 10 }}> <Text style={{ color: '#22C55E', fontSize: 14, fontWeight: '600', marginLeft: 10 }}>
Switching source... {t('player_ui.switching_source')}
</Text> </Text>
</View> </View>
)} )}
@ -191,7 +193,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
fontSize: 14, fontSize: 14,
flex: 1, flex: 1,
}} numberOfLines={1}> }} numberOfLines={1}>
{stream.title || stream.name || `Stream ${index + 1}`} {stream.title || stream.name || t('player_ui.stream', { number: index + 1 })}
</Text> </Text>
<QualityBadge quality={quality} /> <QualityBadge quality={quality} />
</View> </View>
@ -237,7 +239,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}> <View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<MaterialIcons name="cloud-off" size={48} color="white" /> <MaterialIcons name="cloud-off" size={48} color="white" />
<Text style={{ color: 'white', marginTop: 16, textAlign: 'center', fontWeight: '600' }}> <Text style={{ color: 'white', marginTop: 16, textAlign: 'center', fontWeight: '600' }}>
No sources found {t('player_ui.no_sources_found')}
</Text> </Text>
</View> </View>
)} )}

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet } from 'react-native'; import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Animated, { import Animated, {
FadeIn, FadeIn,
FadeOut, FadeOut,
@ -55,6 +56,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
holdToSpeedValue, holdToSpeedValue,
setHoldToSpeedValue, setHoldToSpeedValue,
}) => { }) => {
const { t } = useTranslation();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const speedPresets = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5]; const speedPresets = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5];
const holdSpeedOptions = [1.0, 2.0, 3.0]; const holdSpeedOptions = [1.0, 2.0, 3.0];
@ -85,7 +87,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
}} }}
> >
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20, alignItems: 'center' }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20, alignItems: 'center' }}>
<Text style={{ color: 'white', fontSize: 16, fontWeight: '600' }}>Playback Speed</Text> <Text style={{ color: 'white', fontSize: 16, fontWeight: '600' }}>{t('player_ui.playback_speed')}</Text>
</View> </View>
{/* Speed Selection Row */} {/* Speed Selection Row */}
@ -108,7 +110,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
onPress={() => setHoldToSpeedEnabled(!holdToSpeedEnabled)} onPress={() => setHoldToSpeedEnabled(!holdToSpeedEnabled)}
style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: holdToSpeedEnabled ? 15 : 0 }} style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: holdToSpeedEnabled ? 15 : 0 }}
> >
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }}>On Hold</Text> <Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }}>{t('player_ui.on_hold')}</Text>
<View style={{ <View style={{
width: 34, height: 18, borderRadius: 10, width: 34, height: 18, borderRadius: 10,
backgroundColor: holdToSpeedEnabled ? 'white' : 'rgba(255,255,255,0.2)', backgroundColor: holdToSpeedEnabled ? 'white' : 'rgba(255,255,255,0.2)',

View file

@ -9,6 +9,7 @@ import Animated, {
useAnimatedStyle, useAnimatedStyle,
withTiming, withTiming,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes'; import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes';
import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils'; import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils';
@ -96,6 +97,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
selectedExternalSubtitleId, selectedExternalSubtitleId,
onOpenSyncModal, onOpenSyncModal,
}) => { }) => {
const { t } = useTranslation();
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
const isIos = Platform.OS === 'ios'; const isIos = Platform.OS === 'ios';
const isLandscape = width > height; const isLandscape = width > height;
@ -151,14 +153,14 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
> >
{/* Header */} {/* Header */}
<View style={{ flexDirection: 'row', alignItems: 'center', padding: 20, position: 'relative' }}> <View style={{ flexDirection: 'row', alignItems: 'center', padding: 20, position: 'relative' }}>
<Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>Subtitles</Text> <Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>{t('player_ui.subtitles')}</Text>
</View> </View>
{/* Tab Bar */} {/* Tab Bar */}
<View style={{ flexDirection: 'row', gap: 15, paddingHorizontal: 70, marginBottom: 20 }}> <View style={{ flexDirection: 'row', gap: 15, paddingHorizontal: 70, marginBottom: 20 }}>
<MorphingTab label="Built-in" isSelected={activeTab === 'built-in'} onPress={() => setActiveTab('built-in')} /> <MorphingTab label={t('player_ui.built_in')} isSelected={activeTab === 'built-in'} onPress={() => setActiveTab('built-in')} />
<MorphingTab label="Addons" isSelected={activeTab === 'addon'} onPress={() => setActiveTab('addon')} /> <MorphingTab label={t('player_ui.addons')} isSelected={activeTab === 'addon'} onPress={() => setActiveTab('addon')} />
<MorphingTab label="Style" isSelected={activeTab === 'appearance'} onPress={() => setActiveTab('appearance')} /> <MorphingTab label={t('player_ui.style')} isSelected={activeTab === 'appearance'} onPress={() => setActiveTab('appearance')} />
</View> </View>
<ScrollView showsVerticalScrollIndicator={false}> <ScrollView showsVerticalScrollIndicator={false}>
@ -174,7 +176,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}} }}
style={{ padding: 10, borderRadius: 12, backgroundColor: selectedTextTrack === -1 ? 'white' : 'rgba(242, 184, 181)' }} style={{ padding: 10, borderRadius: 12, backgroundColor: selectedTextTrack === -1 ? 'white' : 'rgba(242, 184, 181)' }}
> >
<Text style={{ color: selectedTextTrack === -1 ? 'black' : 'rgba(96, 20, 16)', fontWeight: '600' }}>None</Text> <Text style={{ color: selectedTextTrack === -1 ? 'black' : 'rgba(96, 20, 16)', fontWeight: '600' }}>{t('player_ui.none')}</Text>
</TouchableOpacity> </TouchableOpacity>
{ksTextTracks.map((track) => ( {ksTextTracks.map((track) => (
<TouchableOpacity <TouchableOpacity
@ -199,7 +201,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{availableSubtitles.length === 0 ? ( {availableSubtitles.length === 0 ? (
<TouchableOpacity onPress={fetchAvailableSubtitles} style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}> <TouchableOpacity onPress={fetchAvailableSubtitles} style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<MaterialIcons name="cloud-download" size={32} color="white" /> <MaterialIcons name="cloud-download" size={32} color="white" />
<Text style={{ color: 'white', marginTop: 10 }}>Search Online Subtitles</Text> <Text style={{ color: 'white', marginTop: 10 }}>{t('player_ui.search_online_subtitles')}</Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
availableSubtitles.map((sub) => ( availableSubtitles.map((sub) => (
@ -230,7 +232,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}> <View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}>
<MaterialIcons name="visibility" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="visibility" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Preview</Text> <Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{t('player_ui.preview')}</Text>
</View> </View>
<View style={{ height: previewHeight, justifyContent: 'flex-end' }}> <View style={{ height: previewHeight, justifyContent: 'flex-end' }}>
<View style={{ alignItems: subtitleAlign === 'center' ? 'center' : subtitleAlign === 'left' ? 'flex-start' : 'flex-end', marginBottom: Math.min(80, subtitleBottomOffset) }}> <View style={{ alignItems: subtitleAlign === 'center' ? 'center' : subtitleAlign === 'left' ? 'flex-start' : 'flex-end', marginBottom: Math.min(80, subtitleBottomOffset) }}>
@ -262,7 +264,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}> <View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}>
<MaterialIcons name="star" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="star" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Quick Presets</Text> <Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{t('player_ui.quick_presets')}</Text>
</View> </View>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}> <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<TouchableOpacity <TouchableOpacity
@ -274,7 +276,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}} }}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }} style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}
> >
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 11 : 12 }}>Default</Text> <Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.default')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@ -282,7 +284,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}} }}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,215,0,0.12)', borderWidth: 1, borderColor: 'rgba(255,215,0,0.35)' }} style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,215,0,0.12)', borderWidth: 1, borderColor: 'rgba(255,215,0,0.35)' }}
> >
<Text style={{ color: '#FFD700', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>Yellow</Text> <Text style={{ color: '#FFD700', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.yellow')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@ -290,7 +292,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}} }}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(34,197,94,0.12)', borderWidth: 1, borderColor: 'rgba(34,197,94,0.35)' }} style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(34,197,94,0.12)', borderWidth: 1, borderColor: 'rgba(34,197,94,0.35)' }}
> >
<Text style={{ color: '#22C55E', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>High Contrast</Text> <Text style={{ color: '#22C55E', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.high_contrast')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
@ -298,7 +300,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}} }}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(59,130,246,0.12)', borderWidth: 1, borderColor: 'rgba(59,130,246,0.35)' }} style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(59,130,246,0.12)', borderWidth: 1, borderColor: 'rgba(59,130,246,0.35)' }}
> >
<Text style={{ color: '#3B82F6', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>Large</Text> <Text style={{ color: '#3B82F6', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.large')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@ -308,12 +310,12 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}> <View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}>
<MaterialIcons name="tune" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="tune" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Core</Text> <Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{t('player_ui.core')}</Text>
</View> </View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}> <View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="format-size" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="format-size" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>Font Size</Text> <Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>{t('player_ui.font_size')}</Text>
</View> </View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<TouchableOpacity onPress={decreaseSubtitleSize} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', justifyContent: 'center', alignItems: 'center' }}> <TouchableOpacity onPress={decreaseSubtitleSize} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', justifyContent: 'center', alignItems: 'center' }}>
@ -332,7 +334,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}> <View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="layers" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="layers" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>Show Background</Text> <Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>{t('player_ui.show_background')}</Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={{ width: isCompact ? 48 : 54, height: isCompact ? 28 : 30, backgroundColor: subtitleBackground ? 'white' : 'rgba(255,255,255,0.25)', borderRadius: 15, justifyContent: 'center', alignItems: subtitleBackground ? 'flex-end' : 'flex-start', paddingHorizontal: 3 }} style={{ width: isCompact ? 48 : 54, height: isCompact ? 28 : 30, backgroundColor: subtitleBackground ? 'white' : 'rgba(255,255,255,0.25)', borderRadius: 15, justifyContent: 'center', alignItems: subtitleBackground ? 'flex-end' : 'flex-start', paddingHorizontal: 3 }}
@ -348,14 +350,14 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}> <View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}> <View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="build" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="build" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{isExoPlayerInternal ? 'Position' : 'Advanced'}</Text> <Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{isExoPlayerInternal ? t('player_ui.position') : t('player_ui.advanced')}</Text>
</View> </View>
{/* Text Color - Not supported on ExoPlayer internal subtitles */} {/* Text Color - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && ( {!isExoPlayerInternal && (
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}> <View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" /> <MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'white', marginLeft: 8, fontWeight: '600' }}>Text Color</Text> <Text style={{ color: 'white', marginLeft: 8, fontWeight: '600' }}>{t('player_ui.text_color')}</Text>
</View> </View>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' }}> <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' }}>
{['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88', '#9b59b6', '#f97316'].map(c => ( {['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88', '#9b59b6', '#f97316'].map(c => (
@ -367,7 +369,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{/* Align - Not supported on ExoPlayer internal subtitles */} {/* Align - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && ( {!isExoPlayerInternal && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Align</Text> <Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.align')}</Text>
<View style={{ flexDirection: 'row', gap: 8 }}> <View style={{ flexDirection: 'row', gap: 8 }}>
{([{ key: 'left', icon: 'format-align-left' }, { key: 'center', icon: 'format-align-center' }, { key: 'right', icon: 'format-align-right' }] as const).map(a => ( {([{ key: 'left', icon: 'format-align-left' }, { key: 'center', icon: 'format-align-center' }, { key: 'right', icon: 'format-align-right' }] as const).map(a => (
<TouchableOpacity key={a.key} onPress={() => setSubtitleAlign(a.key)} style={{ paddingHorizontal: isCompact ? 8 : 10, paddingVertical: isCompact ? 4 : 6, borderRadius: 8, backgroundColor: subtitleAlign === a.key ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}> <TouchableOpacity key={a.key} onPress={() => setSubtitleAlign(a.key)} style={{ paddingHorizontal: isCompact ? 8 : 10, paddingVertical: isCompact ? 4 : 6, borderRadius: 8, backgroundColor: subtitleAlign === a.key ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}>
@ -378,7 +380,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View> </View>
)} )}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Bottom Offset</Text> <Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.bottom_offset')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}> <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleBottomOffset(Math.max(0, subtitleBottomOffset - 5))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> <TouchableOpacity onPress={() => setSubtitleBottomOffset(Math.max(0, subtitleBottomOffset - 5))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="keyboard-arrow-down" color="#fff" size={20} /> <MaterialIcons name="keyboard-arrow-down" color="#fff" size={20} />
@ -394,7 +396,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{/* Background Opacity - Not supported on ExoPlayer internal subtitles */} {/* Background Opacity - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && ( {!isExoPlayerInternal && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Background Opacity</Text> <Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.background_opacity')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}> <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleBgOpacity(Math.max(0, +(subtitleBgOpacity - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> <TouchableOpacity onPress={() => setSubtitleBgOpacity(Math.max(0, +(subtitleBgOpacity - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} /> <MaterialIcons name="remove" color="#fff" size={18} />
@ -410,16 +412,16 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
)} )}
{!isUsingInternalSubtitle && ( {!isUsingInternalSubtitle && (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text> <Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.text_shadow')}</Text>
<TouchableOpacity onPress={() => setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}> <TouchableOpacity onPress={() => setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}>
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleTextShadow ? 'On' : 'Off'}</Text> <Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleTextShadow ? t('player_ui.on') : t('player_ui.off')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
{!isUsingInternalSubtitle && ( {!isUsingInternalSubtitle && (
<> <>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Color</Text> <Text style={{ color: 'white' }}>{t('player_ui.outline_color')}</Text>
<View style={{ flexDirection: 'row', gap: 8 }}> <View style={{ flexDirection: 'row', gap: 8 }}>
{['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => ( {['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} /> <TouchableOpacity key={c} onPress={() => setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
@ -427,7 +429,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View> </View>
</View> </View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Width</Text> <Text style={{ color: 'white' }}>{t('player_ui.outline_width')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}> <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> <TouchableOpacity onPress={() => setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} /> <MaterialIcons name="remove" color="#fff" size={18} />
@ -445,7 +447,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{!isUsingInternalSubtitle && ( {!isUsingInternalSubtitle && (
<View style={{ flexDirection: isCompact ? 'column' : 'row', justifyContent: 'space-between', gap: 12 }}> <View style={{ flexDirection: isCompact ? 'column' : 'row', justifyContent: 'space-between', gap: 12 }}>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Letter Spacing</Text> <Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.letter_spacing')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> <TouchableOpacity onPress={() => setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} /> <MaterialIcons name="remove" color="#fff" size={18} />
@ -459,7 +461,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View> </View>
</View> </View>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Line Height</Text> <Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.line_height')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> <TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} /> <MaterialIcons name="remove" color="#fff" size={18} />
@ -478,7 +480,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{!isExoPlayerInternal && ( {!isExoPlayerInternal && (
<View style={{ marginTop: 4 }}> <View style={{ marginTop: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Timing Offset (s)</Text> <Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.timing_offset')}</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}> <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleOffsetSec(+(subtitleOffsetSec - 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> <TouchableOpacity onPress={() => setSubtitleOffsetSec(+(subtitleOffsetSec - 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} /> <MaterialIcons name="remove" color="#fff" size={18} />
@ -511,10 +513,10 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}} }}
> >
<MaterialIcons name="sync" color="#fff" size={18} style={{ marginRight: 8 }} /> <MaterialIcons name="sync" color="#fff" size={18} style={{ marginRight: 8 }} />
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 14 }}>Visual Sync</Text> <Text style={{ color: '#fff', fontWeight: '600', fontSize: 14 }}>{t('player_ui.visual_sync')}</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>Nudge subtitles earlier (-) or later (+) to sync if needed.</Text> <Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>{t('player_ui.timing_hint')}</Text>
</View> </View>
)} )}
<View style={{ alignItems: 'flex-end', marginTop: 8 }}> <View style={{ alignItems: 'flex-end', marginTop: 8 }}>
@ -527,7 +529,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}} }}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.1)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }} style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.1)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}
> >
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 12 : 14 }}>Reset to defaults</Text> <Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 12 : 14 }}>{t('player_ui.reset_defaults')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>

View file

@ -0,0 +1,155 @@
import React, { useMemo } from 'react';
import { View, Text, FlatList } from 'react-native';
import { useTranslation } from 'react-i18next';
import { AddonSearchResults, StreamingContent } from '../../services/catalogService';
import { SearchResultItem } from './SearchResultItem';
import { isTablet, isLargeTablet, isTV } from './searchUtils';
import { searchStyles as styles } from './searchStyles';
interface AddonSectionProps {
addonGroup: AddonSearchResults;
addonIndex: number;
onItemPress: (item: StreamingContent) => void;
onItemLongPress: (item: StreamingContent) => void;
currentTheme: any;
}
export const AddonSection = React.memo(({
addonGroup,
addonIndex,
onItemPress,
onItemLongPress,
currentTheme,
}: AddonSectionProps) => {
const { t } = useTranslation();
const movieResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'movie'),
[addonGroup.results]
);
const seriesResults = useMemo(() =>
addonGroup.results.filter(item => item.type === 'series'),
[addonGroup.results]
);
const otherResults = useMemo(() =>
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
[addonGroup.results]
);
return (
<View>
{/* Addon Header */}
<View style={styles.addonHeaderContainer}>
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
{addonGroup.addonName}
</Text>
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>
{addonGroup.results.length}
</Text>
</View>
</View>
{/* Movies */}
{movieResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{t('search.movies')} ({movieResults.length})
</Text>
<FlatList
data={movieResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
onPress={onItemPress}
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
{/* TV Shows */}
{seriesResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{t('search.tv_shows')} ({seriesResults.length})
</Text>
<FlatList
data={seriesResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
onPress={onItemPress}
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
{/* Other types */}
{otherResults.length > 0 && (
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
<Text style={[
styles.carouselSubtitle,
{
color: currentTheme.colors.lightGray,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
}
]}>
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
</Text>
<FlatList
data={otherResults}
renderItem={({ item, index }) => (
<SearchResultItem
item={item}
index={index}
onPress={onItemPress}
onLongPress={onItemLongPress}
/>
)}
keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalListContent}
/>
</View>
)}
</View>
);
}, (prev, next) => {
// Only re-render if this section's reference changed
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
});
AddonSection.displayName = 'AddonSection';

View file

@ -0,0 +1,266 @@
import React, { useMemo, useCallback, forwardRef, RefObject } from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons';
import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
import { DiscoverCatalog } from './searchUtils';
import { searchStyles as styles } from './searchStyles';
interface DiscoverBottomSheetsProps {
typeSheetRef: RefObject<BottomSheetModal>;
catalogSheetRef: RefObject<BottomSheetModal>;
genreSheetRef: RefObject<BottomSheetModal>;
selectedDiscoverType: 'movie' | 'series';
selectedCatalog: DiscoverCatalog | null;
selectedDiscoverGenre: string | null;
filteredCatalogs: DiscoverCatalog[];
availableGenres: string[];
onTypeSelect: (type: 'movie' | 'series') => void;
onCatalogSelect: (catalog: DiscoverCatalog) => void;
onGenreSelect: (genre: string | null) => void;
currentTheme: any;
}
export const DiscoverBottomSheets = ({
typeSheetRef,
catalogSheetRef,
genreSheetRef,
selectedDiscoverType,
selectedCatalog,
selectedDiscoverGenre,
filteredCatalogs,
availableGenres,
onTypeSelect,
onCatalogSelect,
onGenreSelect,
currentTheme,
}: DiscoverBottomSheetsProps) => {
const { t } = useTranslation();
const typeSnapPoints = useMemo(() => ['25%'], []);
const catalogSnapPoints = useMemo(() => ['50%'], []);
const genreSnapPoints = useMemo(() => ['50%'], []);
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
return (
<>
{/* Catalog Selection Bottom Sheet */}
<BottomSheetModal
ref={catalogSheetRef}
index={0}
snapPoints={catalogSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
}}
>
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
{t('search.select_catalog')}
</Text>
<TouchableOpacity onPress={() => catalogSheetRef.current?.dismiss()}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
<BottomSheetScrollView
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
{filteredCatalogs.map((catalog, index) => (
<TouchableOpacity
key={`${catalog.addonId}-${catalog.catalogId}-${index}`}
style={[
styles.bottomSheetItem,
selectedCatalog?.catalogId === catalog.catalogId &&
selectedCatalog?.addonId === catalog.addonId &&
styles.bottomSheetItemSelected
]}
onPress={() => onCatalogSelect(catalog)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{catalog.catalogName}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{catalog.addonName}
</Text>
</View>
{selectedCatalog?.catalogId === catalog.catalogId &&
selectedCatalog?.addonId === catalog.addonId && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
))}
</BottomSheetScrollView>
</BottomSheetModal>
{/* Genre Selection Bottom Sheet */}
<BottomSheetModal
ref={genreSheetRef}
index={0}
snapPoints={genreSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
android_keyboardInputMode="adjustResize"
animateOnMount={true}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
}}
>
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
{t('search.select_genre')}
</Text>
<TouchableOpacity onPress={() => genreSheetRef.current?.dismiss()}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
<BottomSheetScrollView
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
{/* All Genres option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
!selectedDiscoverGenre && styles.bottomSheetItemSelected
]}
onPress={() => onGenreSelect(null)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.all_genres')}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.show_all_content')}
</Text>
</View>
{!selectedDiscoverGenre && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
{/* Genre options */}
{availableGenres.map((genre, index) => (
<TouchableOpacity
key={`${genre}-${index}`}
style={[
styles.bottomSheetItem,
selectedDiscoverGenre === genre && styles.bottomSheetItemSelected
]}
onPress={() => onGenreSelect(genre)}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{genre}
</Text>
</View>
{selectedDiscoverGenre === genre && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
))}
</BottomSheetScrollView>
</BottomSheetModal>
{/* Type Selection Bottom Sheet */}
<BottomSheetModal
ref={typeSheetRef}
index={0}
snapPoints={typeSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
}}
>
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
{t('search.select_type')}
</Text>
<TouchableOpacity onPress={() => typeSheetRef.current?.dismiss()}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
<BottomSheetScrollView
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
{/* Movies option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
selectedDiscoverType === 'movie' && styles.bottomSheetItemSelected
]}
onPress={() => onTypeSelect('movie')}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.movies')}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.browse_movies')}
</Text>
</View>
{selectedDiscoverType === 'movie' && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
{/* TV Shows option */}
<TouchableOpacity
style={[
styles.bottomSheetItem,
selectedDiscoverType === 'series' && styles.bottomSheetItemSelected
]}
onPress={() => onTypeSelect('series')}
>
<View style={styles.bottomSheetItemContent}>
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
{t('search.tv_shows')}
</Text>
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
{t('search.browse_tv')}
</Text>
</View>
{selectedDiscoverType === 'series' && (
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</BottomSheetScrollView>
</BottomSheetModal>
</>
);
};
DiscoverBottomSheets.displayName = 'DiscoverBottomSheets';

View file

@ -0,0 +1,159 @@
import React, { useMemo, useEffect, useState } from 'react';
import { View, Text, TouchableOpacity, Dimensions, DeviceEventEmitter } from 'react-native';
import { MaterialIcons, Feather } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image';
import { StreamingContent, catalogService } from '../../services/catalogService';
import { mmkvStorage } from '../../services/mmkvStorage';
import { useSettings } from '../../hooks/useSettings';
import {
isTablet,
isLargeTablet,
isTV,
HORIZONTAL_ITEM_WIDTH,
HORIZONTAL_POSTER_HEIGHT,
PLACEHOLDER_POSTER,
} from './searchUtils';
import { searchStyles as styles } from './searchStyles';
const { width } = Dimensions.get('window');
interface DiscoverResultItemProps {
item: StreamingContent;
index: number;
navigation: any;
setSelectedItem: (item: StreamingContent) => void;
setMenuVisible: (visible: boolean) => void;
currentTheme: any;
isGrid?: boolean;
}
export const DiscoverResultItem = React.memo(({
item,
index,
navigation,
setSelectedItem,
setMenuVisible,
currentTheme,
isGrid = false
}: DiscoverResultItemProps) => {
const { settings } = useSettings();
const [inLibrary, setInLibrary] = useState(!!item.inLibrary);
const [watched, setWatched] = useState(false);
// Calculate dimensions based on poster shape
const { itemWidth, aspectRatio } = useMemo(() => {
const shape = item.posterShape || 'poster';
const baseHeight = HORIZONTAL_POSTER_HEIGHT;
let w = HORIZONTAL_ITEM_WIDTH;
let r = 2 / 3;
if (isGrid) {
// Grid Calculation: (Window Width - Padding) / Columns
const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3;
const totalPadding = 32;
const totalGap = 12 * (Math.max(3, columns) - 1);
const availableWidth = width - totalPadding - totalGap;
w = availableWidth / Math.max(3, columns);
} else {
if (shape === 'landscape') {
r = 16 / 9;
w = baseHeight * r;
} else if (shape === 'square') {
r = 1;
w = baseHeight;
}
}
return { itemWidth: w, aspectRatio: r };
}, [item.posterShape, isGrid]);
useEffect(() => {
const updateWatched = () => {
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
};
updateWatched();
const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched);
return () => sub.remove();
}, [item.id, item.type]);
useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type);
setInLibrary(!!found);
});
return () => unsubscribe();
}, [item.id, item.type]);
return (
<TouchableOpacity
style={[
styles.horizontalItem,
{ width: itemWidth },
isGrid && styles.discoverGridItem
]}
onPress={() => {
navigation.navigate('Metadata', {
id: item.id,
type: item.type,
addonId: item.addonId
});
}}
onLongPress={() => {
setSelectedItem(item);
setMenuVisible(true);
}}
delayLongPress={300}
activeOpacity={0.7}
>
<View style={[styles.horizontalItemPosterContainer, {
width: itemWidth,
height: undefined,
aspectRatio: aspectRatio,
backgroundColor: currentTheme.colors.darkBackground,
borderRadius: settings.posterBorderRadius ?? 12,
}]}>
<FastImage
source={{
uri: item.poster || PLACEHOLDER_POSTER,
priority: FastImage.priority.low,
cache: FastImage.cacheControl.immutable,
}}
style={[styles.horizontalItemPoster, { borderRadius: settings.posterBorderRadius ?? 12 }]}
resizeMode={FastImage.resizeMode.cover}
/>
{/* Bookmark icon */}
{inLibrary && (
<View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }]}>
<Feather name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
{/* Watched icon */}
{watched && (
<View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }]}>
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
</View>
<Text
style={[
styles.horizontalItemTitle,
{
color: currentTheme.colors.white,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 14,
lineHeight: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 18,
}
]}
numberOfLines={2}
>
{item.name}
</Text>
{item.year && (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray, fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 12 }]}>
{item.year}
</Text>
)}
</TouchableOpacity>
);
});
DiscoverResultItem.displayName = 'DiscoverResultItem';

View file

@ -0,0 +1,198 @@
import React from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
ActivityIndicator,
FlatList,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons';
import { StreamingContent } from '../../services/catalogService';
import { DiscoverCatalog, isTablet, isLargeTablet, isTV } from './searchUtils';
import { DiscoverResultItem } from './DiscoverResultItem';
import { searchStyles as styles } from './searchStyles';
import { BottomSheetModal } from '@gorhom/bottom-sheet';
interface DiscoverSectionProps {
discoverLoading: boolean;
discoverInitialized: boolean;
discoverResults: StreamingContent[];
pendingDiscoverResults: StreamingContent[];
loadingMore: boolean;
selectedCatalog: DiscoverCatalog | null;
selectedDiscoverType: 'movie' | 'series';
selectedDiscoverGenre: string | null;
availableGenres: string[];
typeSheetRef: React.RefObject<BottomSheetModal>;
catalogSheetRef: React.RefObject<BottomSheetModal>;
genreSheetRef: React.RefObject<BottomSheetModal>;
handleShowMore: () => void;
navigation: any;
setSelectedItem: (item: StreamingContent) => void;
setMenuVisible: (visible: boolean) => void;
currentTheme: any;
}
export const DiscoverSection = ({
discoverLoading,
discoverInitialized,
discoverResults,
pendingDiscoverResults,
loadingMore,
selectedCatalog,
selectedDiscoverType,
selectedDiscoverGenre,
availableGenres,
typeSheetRef,
catalogSheetRef,
genreSheetRef,
handleShowMore,
navigation,
setSelectedItem,
setMenuVisible,
currentTheme,
}: DiscoverSectionProps) => {
const { t } = useTranslation();
return (
<View style={styles.discoverContainer}>
{/* Section Header */}
<View style={styles.discoverHeader}>
<Text style={[styles.discoverTitle, { color: currentTheme.colors.white }]}>
{t('search.discover')}
</Text>
</View>
{/* Filter Chips Row */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.discoverChipsScroll}
contentContainerStyle={styles.discoverChipsContent}
>
{/* Type Selector Chip (Movie/TV Show) */}
<TouchableOpacity
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => typeSheetRef.current?.present()}
>
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedDiscoverType === 'movie' ? t('search.movies') : t('search.tv_shows')}
</Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
{/* Catalog Selector Chip */}
<TouchableOpacity
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => catalogSheetRef.current?.present()}
>
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedCatalog ? selectedCatalog.catalogName : t('search.select_catalog')}
</Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
{/* Genre Selector Chip - only show if catalog has genres */}
{availableGenres.length > 0 && (
<TouchableOpacity
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => genreSheetRef.current?.present()}
>
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
{selectedDiscoverGenre || t('search.all_genres')}
</Text>
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
)}
</ScrollView>
{/* Selected filters summary */}
{selectedCatalog && (
<View style={styles.discoverFilterSummary}>
<Text style={[styles.discoverFilterSummaryText, { color: currentTheme.colors.lightGray }]}>
{selectedCatalog.addonName} {selectedCatalog.type === 'movie' ? t('search.movies') : t('search.tv_shows')}
{selectedDiscoverGenre ? `${selectedDiscoverGenre}` : ''}
</Text>
</View>
)}
{/* Discover Results */}
{discoverLoading ? (
<View style={styles.discoverLoadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.discoverLoadingText, { color: currentTheme.colors.lightGray }]}>
{t('search.discovering')}
</Text>
</View>
) : discoverResults.length > 0 ? (
<FlatList
data={discoverResults}
keyExtractor={(item, index) => `discover-${item.id}-${index}`}
numColumns={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
key={isTV ? 'tv-6' : isLargeTablet ? 'ltab-5' : isTablet ? 'tab-4' : 'phone-3'}
columnWrapperStyle={styles.discoverGridRow}
contentContainerStyle={styles.discoverGridContent}
renderItem={({ item, index }) => (
<DiscoverResultItem
key={`discover-${item.id}-${index}`}
item={item}
index={index}
navigation={navigation}
setSelectedItem={setSelectedItem}
setMenuVisible={setMenuVisible}
currentTheme={currentTheme}
isGrid={true}
/>
)}
initialNumToRender={9}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={true}
scrollEnabled={false}
ListFooterComponent={
pendingDiscoverResults.length > 0 ? (
<TouchableOpacity
style={styles.showMoreButton}
onPress={handleShowMore}
activeOpacity={0.7}
>
<Text style={[styles.showMoreButtonText, { color: currentTheme.colors.white }]}>
{t('search.show_more', { count: pendingDiscoverResults.length })}
</Text>
<MaterialIcons name="expand-more" size={20} color={currentTheme.colors.white} />
</TouchableOpacity>
) : loadingMore ? (
<View style={styles.loadingMoreContainer}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
) : null
}
/>
) : discoverInitialized && !discoverLoading && selectedCatalog ? (
<View style={styles.discoverEmptyContainer}>
<MaterialIcons name="movie-filter" size={48} color={currentTheme.colors.lightGray} />
<Text style={[styles.discoverEmptyText, { color: currentTheme.colors.lightGray }]}>
{t('search.no_content_found')}
</Text>
<Text style={[styles.discoverEmptySubtext, { color: currentTheme.colors.mediumGray }]}>
{t('search.try_different')}
</Text>
</View>
) : !selectedCatalog && discoverInitialized ? (
<View style={styles.discoverEmptyContainer}>
<MaterialIcons name="touch-app" size={48} color={currentTheme.colors.lightGray} />
<Text style={[styles.discoverEmptyText, { color: currentTheme.colors.lightGray }]}>
{t('search.select_catalog_desc')}
</Text>
<Text style={[styles.discoverEmptySubtext, { color: currentTheme.colors.mediumGray }]}>
{t('search.tap_catalog_desc')}
</Text>
</View>
) : null}
</View>
);
};
DiscoverSection.displayName = 'DiscoverSection';

View file

@ -1,6 +1,11 @@
// Search components barrel export // Search components barrel export
export * from './searchUtils'; export * from './searchUtils';
export { searchStyles } from './searchStyles';
export { SearchSkeletonLoader } from './SearchSkeletonLoader'; export { SearchSkeletonLoader } from './SearchSkeletonLoader';
export { SearchAnimation } from './SearchAnimation'; export { SearchAnimation } from './SearchAnimation';
export { SearchResultItem } from './SearchResultItem'; export { SearchResultItem } from './SearchResultItem';
export { RecentSearches } from './RecentSearches'; export { RecentSearches } from './RecentSearches';
export { DiscoverResultItem } from './DiscoverResultItem';
export { AddonSection } from './AddonSection';
export { DiscoverSection } from './DiscoverSection';
export { DiscoverBottomSheets } from './DiscoverBottomSheets';

View file

@ -0,0 +1,531 @@
import { StyleSheet, Platform, Dimensions } from 'react-native';
import { isTablet, isTV, isLargeTablet, HORIZONTAL_ITEM_WIDTH, HORIZONTAL_POSTER_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT } from './searchUtils';
const { width } = Dimensions.get('window');
export const searchStyles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
flex: 1,
paddingTop: 0,
},
searchBarContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
height: 48,
},
searchBarWrapper: {
flex: 1,
height: 48,
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 12,
paddingHorizontal: 16,
height: '100%',
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3.84,
elevation: 5,
},
searchIcon: {
marginRight: 12,
},
searchInput: {
flex: 1,
fontSize: 16,
height: '100%',
},
clearButton: {
padding: 4,
},
scrollView: {
flex: 1,
},
scrollViewContent: {
paddingBottom: isTablet ? 120 : 100,
paddingHorizontal: 0,
},
carouselContainer: {
marginBottom: isTablet ? 32 : 24,
},
carouselTitle: {
fontSize: isTablet ? 20 : 18,
fontWeight: '700',
marginBottom: isTablet ? 16 : 12,
paddingHorizontal: 16,
},
carouselSubtitle: {
fontSize: isTablet ? 16 : 14,
fontWeight: '600',
marginBottom: isTablet ? 12 : 8,
paddingHorizontal: 16,
},
addonHeaderContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: isTablet ? 16 : 12,
marginTop: isTablet ? 24 : 16,
marginBottom: isTablet ? 8 : 4,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
addonHeaderIcon: {
// removed icon
},
addonHeaderText: {
fontSize: isTablet ? 18 : 16,
fontWeight: '700',
flex: 1,
},
addonHeaderBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
addonHeaderBadgeText: {
fontSize: isTablet ? 12 : 11,
fontWeight: '600',
},
horizontalListContent: {
paddingHorizontal: 16,
},
horizontalItem: {
width: HORIZONTAL_ITEM_WIDTH,
marginRight: 16,
},
horizontalItemPosterContainer: {
width: HORIZONTAL_ITEM_WIDTH,
height: HORIZONTAL_POSTER_HEIGHT,
borderRadius: 12,
overflow: 'hidden',
marginBottom: 8,
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
elevation: Platform.OS === 'android' ? 1 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
},
horizontalItemPoster: {
width: '100%',
height: '100%',
},
horizontalItemTitle: {
fontSize: isTablet ? 12 : 14,
fontWeight: '600',
lineHeight: isTablet ? 16 : 18,
textAlign: 'left',
},
yearText: {
fontSize: isTablet ? 10 : 12,
marginTop: 2,
},
recentSearchesContainer: {
paddingHorizontal: 16,
paddingBottom: isTablet ? 24 : 16,
paddingTop: isTablet ? 12 : 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.05)',
marginBottom: isTablet ? 16 : 8,
},
recentSearchItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: isTablet ? 12 : 10,
paddingHorizontal: 16,
marginVertical: 1,
},
recentSearchIcon: {
marginRight: 12,
},
recentSearchText: {
fontSize: 16,
flex: 1,
},
recentSearchDeleteButton: {
padding: 4,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: isTablet ? 64 : 32,
paddingBottom: isTablet ? 120 : 100,
},
emptyText: {
fontSize: 18,
fontWeight: 'bold',
marginTop: 16,
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
skeletonContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 12,
paddingTop: 16,
justifyContent: 'space-between',
},
skeletonVerticalItem: {
flexDirection: 'row',
marginBottom: 16,
},
skeletonPoster: {
width: POSTER_WIDTH,
height: POSTER_HEIGHT,
borderRadius: 12,
},
skeletonItemDetails: {
flex: 1,
marginLeft: 16,
justifyContent: 'center',
},
skeletonMetaRow: {
flexDirection: 'row',
gap: 8,
marginTop: 8,
},
skeletonTitle: {
height: 20,
width: '80%',
marginBottom: 8,
borderRadius: 4,
},
skeletonMeta: {
height: 14,
width: '30%',
borderRadius: 4,
},
skeletonSectionHeader: {
height: 24,
width: '40%',
marginBottom: 16,
borderRadius: 4,
},
ratingContainer: {
position: 'absolute',
bottom: 8,
right: 8,
backgroundColor: 'rgba(0,0,0,0.7)',
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
},
ratingText: {
fontSize: isTablet ? 9 : 10,
fontWeight: '700',
marginLeft: 2,
},
simpleAnimationContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
simpleAnimationContent: {
alignItems: 'center',
},
spinnerContainer: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 3,
},
simpleAnimationText: {
fontSize: 16,
fontWeight: '600',
},
watchedIndicator: {
position: 'absolute',
top: 8,
right: 8,
borderRadius: 12,
padding: 2,
zIndex: 2,
backgroundColor: 'transparent',
},
libraryBadge: {
position: 'absolute',
top: 8,
left: 8,
borderRadius: 8,
padding: 4,
zIndex: 2,
backgroundColor: 'transparent',
},
// Discover section styles
discoverContainer: {
paddingTop: isTablet ? 16 : 12,
paddingBottom: isTablet ? 24 : 16,
},
discoverHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: isTablet ? 16 : 12,
gap: 8,
},
discoverTitle: {
fontSize: isTablet ? 22 : 20,
fontWeight: '700',
},
discoverTypeContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
marginBottom: isTablet ? 16 : 12,
gap: 12,
},
discoverTypeButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.1)',
gap: 6,
},
discoverTypeText: {
fontSize: isTablet ? 15 : 14,
fontWeight: '600',
},
discoverGenreScroll: {
marginBottom: isTablet ? 20 : 16,
},
discoverGenreContent: {
paddingHorizontal: 16,
gap: 8,
},
discoverGenreChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: 'rgba(255,255,255,0.08)',
marginRight: 8,
},
discoverGenreChipActive: {
backgroundColor: 'rgba(255,255,255,0.2)',
},
discoverGenreText: {
fontSize: isTablet ? 14 : 13,
fontWeight: '500',
color: 'rgba(255,255,255,0.7)',
},
discoverGenreTextActive: {
color: '#FFFFFF',
fontWeight: '600',
},
discoverLoadingContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
},
discoverLoadingText: {
marginTop: 12,
fontSize: 14,
},
discoverAddonSection: {
marginBottom: isTablet ? 28 : 20,
},
discoverAddonHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: isTablet ? 12 : 8,
},
discoverAddonName: {
fontSize: isTablet ? 16 : 15,
fontWeight: '600',
flex: 1,
},
discoverAddonBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
},
discoverAddonBadgeText: {
fontSize: 11,
fontWeight: '600',
},
discoverEmptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 60,
paddingHorizontal: 32,
},
discoverEmptyText: {
fontSize: 16,
fontWeight: '600',
marginTop: 12,
textAlign: 'center',
},
discoverEmptySubtext: {
fontSize: 14,
marginTop: 4,
textAlign: 'center',
},
discoverGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 16,
gap: 12,
},
discoverGridRow: {
justifyContent: 'flex-start',
gap: 12,
},
discoverGridContent: {
paddingHorizontal: 16,
paddingBottom: 16,
},
discoverGridItem: {
marginRight: 0,
marginBottom: 12,
},
loadingMoreContainer: {
width: '100%',
paddingVertical: 16,
alignItems: 'center',
justifyContent: 'center',
},
// New chip-based discover styles
discoverChipsScroll: {
marginBottom: isTablet ? 12 : 10,
flexGrow: 0,
},
discoverChipsContent: {
paddingHorizontal: 16,
flexDirection: 'row',
gap: 8,
},
discoverSelectorChip: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
gap: 6,
},
discoverSelectorText: {
fontSize: isTablet ? 14 : 13,
fontWeight: '600',
},
discoverFilterSummary: {
paddingHorizontal: 16,
marginBottom: isTablet ? 16 : 12,
},
discoverFilterSummaryText: {
fontSize: 12,
fontWeight: '500',
},
// Bottom sheet styles
bottomSheetHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
bottomSheetTitle: {
fontSize: 18,
fontWeight: '700',
},
bottomSheetContent: {
paddingHorizontal: 12,
paddingBottom: 40,
},
bottomSheetItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 12,
borderRadius: 12,
marginVertical: 2,
},
bottomSheetItemSelected: {
backgroundColor: 'rgba(255,255,255,0.08)',
},
bottomSheetItemIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.1)',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
bottomSheetItemContent: {
flex: 1,
},
bottomSheetItemTitle: {
fontSize: 16,
fontWeight: '600',
},
bottomSheetItemSubtitle: {
fontSize: 13,
marginTop: 2,
},
showMoreButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 12,
paddingHorizontal: 24,
backgroundColor: 'rgba(255,255,255,0.1)',
borderRadius: 8,
marginVertical: 20,
alignSelf: 'center',
},
showMoreButtonText: {
fontSize: 14,
fontWeight: '600',
marginRight: 8,
},
});

21
src/i18n/index.ts Normal file
View file

@ -0,0 +1,21 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import 'intl-pluralrules';
import languageDetector from './languageDetector';
import { resources } from './resources';
i18n
.use(languageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
export default i18n;

View file

@ -0,0 +1,32 @@
import { getLocales } from 'expo-localization';
import { LanguageDetectorAsyncModule } from 'i18next';
import { mmkvStorage } from '../services/mmkvStorage';
const languageDetector: LanguageDetectorAsyncModule = {
type: 'languageDetector',
async: true,
detect: (callback: (lng: string | undefined) => void): void => {
const findLanguage = async () => {
try {
const savedLanguage = await mmkvStorage.getItem('user_language');
if (savedLanguage) {
callback(savedLanguage);
return;
}
} catch (error) {
console.log('Error reading language from storage', error);
}
const locales = getLocales();
const languageCode = locales[0]?.languageCode ?? 'en';
callback(languageCode);
};
findLanguage();
},
init: () => { },
cacheUserLanguage: (language: string) => {
mmkvStorage.setItem('user_language', language);
},
};
export default languageDetector;

1196
src/i18n/locales/ar.json Normal file

File diff suppressed because it is too large Load diff

1196
src/i18n/locales/en.json Normal file

File diff suppressed because it is too large Load diff

1196
src/i18n/locales/es.json Normal file

File diff suppressed because it is too large Load diff

1196
src/i18n/locales/fr.json Normal file

File diff suppressed because it is too large Load diff

1162
src/i18n/locales/pt.json Normal file

File diff suppressed because it is too large Load diff

13
src/i18n/resources.ts Normal file
View file

@ -0,0 +1,13 @@
import en from './locales/en.json';
import pt from './locales/pt.json';
import ar from './locales/ar.json';
import es from './locales/es.json';
import fr from './locales/fr.json';
export const resources = {
en: { translation: en },
pt: { translation: pt },
ar: { translation: ar },
es: { translation: es },
fr: { translation: fr },
};

View file

@ -17,6 +17,7 @@ import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-cont
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { PostHogProvider } from 'posthog-react-native'; import { PostHogProvider } from 'posthog-react-native';
import { ScrollToTopProvider, useScrollToTopEmitter } from '../contexts/ScrollToTopContext'; import { ScrollToTopProvider, useScrollToTopEmitter } from '../contexts/ScrollToTopContext';
import { useTranslation } from 'react-i18next';
// Optional iOS Glass effect (expo-glass-effect) with safe fallback // Optional iOS Glass effect (expo-glass-effect) with safe fallback
let GlassViewComp: any = null; let GlassViewComp: any = null;
@ -545,6 +546,7 @@ const WrappedScreen: React.FC<{ Screen: React.ComponentType<any> }> = ({ Screen
// Tab Navigator // Tab Navigator
const MainTabs = () => { const MainTabs = () => {
const { t } = useTranslation();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = require('../hooks/useSettings'); const { settings } = require('../hooks/useSettings');
const { useSettings: useSettingsHook } = require('../hooks/useSettings'); const { useSettings: useSettingsHook } = require('../hooks/useSettings');
@ -915,7 +917,7 @@ const MainTabs = () => {
name="Home" name="Home"
component={HomeScreen} component={HomeScreen}
options={{ options={{
title: 'Home', title: t('navigation.home'),
tabBarIcon: () => ({ sfSymbol: 'house' }), tabBarIcon: () => ({ sfSymbol: 'house' }),
freezeOnBlur: true, freezeOnBlur: true,
}} }}
@ -931,7 +933,7 @@ const MainTabs = () => {
name="Library" name="Library"
component={LibraryScreen} component={LibraryScreen}
options={{ options={{
title: 'Library', title: t('navigation.library'),
tabBarIcon: () => ({ sfSymbol: 'heart' }), tabBarIcon: () => ({ sfSymbol: 'heart' }),
}} }}
listeners={({ navigation }: { navigation: any }) => ({ listeners={({ navigation }: { navigation: any }) => ({
@ -946,7 +948,7 @@ const MainTabs = () => {
name="Search" name="Search"
component={SearchScreen} component={SearchScreen}
options={{ options={{
title: 'Search', title: t('navigation.search'),
tabBarIcon: () => ({ sfSymbol: 'magnifyingglass' }), tabBarIcon: () => ({ sfSymbol: 'magnifyingglass' }),
}} }}
listeners={({ navigation }: { navigation: any }) => ({ listeners={({ navigation }: { navigation: any }) => ({
@ -962,7 +964,7 @@ const MainTabs = () => {
name="Downloads" name="Downloads"
component={DownloadsScreen} component={DownloadsScreen}
options={{ options={{
title: 'Downloads', title: t('navigation.downloads'),
tabBarIcon: () => ({ sfSymbol: 'arrow.down.circle' }), tabBarIcon: () => ({ sfSymbol: 'arrow.down.circle' }),
}} }}
listeners={({ navigation }: { navigation: any }) => ({ listeners={({ navigation }: { navigation: any }) => ({
@ -978,7 +980,7 @@ const MainTabs = () => {
name="Settings" name="Settings"
component={SettingsScreen} component={SettingsScreen}
options={{ options={{
title: 'Settings', title: t('navigation.settings'),
tabBarIcon: () => ({ sfSymbol: 'gear' }), tabBarIcon: () => ({ sfSymbol: 'gear' }),
}} }}
listeners={({ navigation }: { navigation: any }) => ({ listeners={({ navigation }: { navigation: any }) => ({
@ -1053,7 +1055,7 @@ const MainTabs = () => {
name="Home" name="Home"
component={HomeScreen} component={HomeScreen}
options={{ options={{
tabBarLabel: 'Home', tabBarLabel: t('navigation.home'),
tabBarIcon: ({ color, size, focused }) => ( tabBarIcon: ({ color, size, focused }) => (
<MaterialCommunityIcons name={focused ? 'home' : 'home-outline'} size={size} color={color} /> <MaterialCommunityIcons name={focused ? 'home' : 'home-outline'} size={size} color={color} />
), ),
@ -1064,7 +1066,7 @@ const MainTabs = () => {
name="Library" name="Library"
component={LibraryScreen} component={LibraryScreen}
options={{ options={{
tabBarLabel: 'Library', tabBarLabel: t('navigation.library'),
tabBarIcon: ({ color, size, focused }) => ( tabBarIcon: ({ color, size, focused }) => (
<MaterialCommunityIcons name={focused ? 'heart' : 'heart-outline'} size={size} color={color} /> <MaterialCommunityIcons name={focused ? 'heart' : 'heart-outline'} size={size} color={color} />
), ),
@ -1074,7 +1076,7 @@ const MainTabs = () => {
name="Search" name="Search"
component={SearchScreen} component={SearchScreen}
options={{ options={{
tabBarLabel: 'Search', tabBarLabel: t('navigation.search'),
tabBarIcon: ({ color, size }) => ( tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons name={'magnify'} size={size} color={color} /> <MaterialCommunityIcons name={'magnify'} size={size} color={color} />
), ),
@ -1085,7 +1087,7 @@ const MainTabs = () => {
name="Downloads" name="Downloads"
component={DownloadsScreen} component={DownloadsScreen}
options={{ options={{
tabBarLabel: 'Downloads', tabBarLabel: t('navigation.downloads'),
tabBarIcon: ({ color, size, focused }) => ( tabBarIcon: ({ color, size, focused }) => (
<MaterialCommunityIcons name={focused ? 'download' : 'download-outline'} size={size} color={color} /> <MaterialCommunityIcons name={focused ? 'download' : 'download-outline'} size={size} color={color} />
), ),
@ -1096,7 +1098,7 @@ const MainTabs = () => {
name="Settings" name="Settings"
component={SettingsScreen} component={SettingsScreen}
options={{ options={{
tabBarLabel: 'Settings', tabBarLabel: t('navigation.settings'),
tabBarIcon: ({ color, size, focused }) => ( tabBarIcon: ({ color, size, focused }) => (
<MaterialCommunityIcons name={focused ? 'cog' : 'cog-outline'} size={size} color={color} /> <MaterialCommunityIcons name={focused ? 'cog' : 'cog-outline'} size={size} color={color} />
), ),

Binary file not shown.

View file

@ -21,11 +21,13 @@ import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { SvgXml } from 'react-native-svg'; import { SvgXml } from 'react-native-svg';
import { useTranslation } from 'react-i18next';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
const isTablet = width >= 768; const isTablet = width >= 768;
const AISettingsScreen: React.FC = () => { const AISettingsScreen: React.FC = () => {
const { t } = useTranslation();
// CustomAlert state (must be inside the component) // CustomAlert state (must be inside the component)
const [alertVisible, setAlertVisible] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState(''); const [alertTitle, setAlertTitle] = useState('');
@ -69,7 +71,7 @@ const AISettingsScreen: React.FC = () => {
<path stroke-width=".4" d="m244.1 250.4-60.3-34.7v69.5l60.3-34.8Z"/> <path stroke-width=".4" d="m244.1 250.4-60.3-34.7v69.5l60.3-34.8Z"/>
</g> </g>
</svg>`; </svg>`;
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isKeySet, setIsKeySet] = useState(false); const [isKeySet, setIsKeySet] = useState(false);
@ -92,12 +94,12 @@ const AISettingsScreen: React.FC = () => {
const handleSaveApiKey = async () => { const handleSaveApiKey = async () => {
if (!apiKey.trim()) { if (!apiKey.trim()) {
openAlert('Error', 'Please enter a valid API key'); openAlert(t('common.error'), t('ai_settings.error_invalid_key'));
return; return;
} }
if (!apiKey.startsWith('sk-or-')) { if (!apiKey.startsWith('sk-or-')) {
openAlert('Error', 'OpenRouter API keys should start with "sk-or-"'); openAlert(t('common.error'), t('ai_settings.error_key_format'));
return; return;
} }
@ -105,9 +107,9 @@ const AISettingsScreen: React.FC = () => {
try { try {
await mmkvStorage.setItem('openrouter_api_key', apiKey.trim()); await mmkvStorage.setItem('openrouter_api_key', apiKey.trim());
setIsKeySet(true); setIsKeySet(true);
openAlert('Success', 'OpenRouter API key saved successfully!'); openAlert(t('common.success'), t('ai_settings.success_saved'));
} catch (error) { } catch (error) {
openAlert('Error', 'Failed to save API key'); openAlert(t('common.error'), t('ai_settings.error_save'));
if (__DEV__) console.error('Error saving OpenRouter API key:', error); if (__DEV__) console.error('Error saving OpenRouter API key:', error);
} finally { } finally {
setLoading(false); setLoading(false);
@ -116,10 +118,10 @@ const AISettingsScreen: React.FC = () => {
const handleRemoveApiKey = () => { const handleRemoveApiKey = () => {
openAlert( openAlert(
'Remove API Key', t('ai_settings.confirm_remove_title'),
'Are you sure you want to remove your OpenRouter API key? This will disable AI chat features.', t('ai_settings.confirm_remove_msg'),
[ [
{ label: 'Cancel', onPress: () => {} }, { label: t('common.cancel'), onPress: () => { } },
{ {
label: 'Remove', label: 'Remove',
onPress: async () => { onPress: async () => {
@ -127,9 +129,9 @@ const AISettingsScreen: React.FC = () => {
await mmkvStorage.removeItem('openrouter_api_key'); await mmkvStorage.removeItem('openrouter_api_key');
setApiKey(''); setApiKey('');
setIsKeySet(false); setIsKeySet(false);
openAlert('Success', 'API key removed successfully'); openAlert(t('common.success'), t('ai_settings.success_removed'));
} catch (error) { } catch (error) {
openAlert('Error', 'Failed to remove API key'); openAlert(t('common.error'), t('ai_settings.error_remove'));
} }
} }
} }
@ -142,35 +144,35 @@ const AISettingsScreen: React.FC = () => {
}; };
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity <TouchableOpacity
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
style={styles.backButton} style={styles.backButton}
> >
<MaterialIcons <MaterialIcons
name="arrow-back" name="arrow-back"
size={24} size={24}
color={currentTheme.colors.text} color={currentTheme.colors.text}
/> />
<Text style={[styles.backText, { color: currentTheme.colors.text }]}> <Text style={[styles.backText, { color: currentTheme.colors.text }]}>
Settings {t('settings.settings_title')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerActions}> <View style={styles.headerActions}>
{/* Empty for now, but ready for future actions */} {/* Empty for now, but ready for future actions */}
</View> </View>
</View> </View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}> <Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
AI Assistant {t('ai_settings.title')}
</Text> </Text>
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@ -178,42 +180,42 @@ const AISettingsScreen: React.FC = () => {
{/* Info Card */} {/* Info Card */}
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.infoHeader}> <View style={styles.infoHeader}>
<MaterialIcons <MaterialIcons
name="smart-toy" name="smart-toy"
size={24} size={24}
color={currentTheme.colors.primary} color={currentTheme.colors.primary}
/> />
<Text style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}>
AI-Powered Chat {t('ai_settings.info_title')}
</Text> </Text>
</View> </View>
<Text style={[styles.infoDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.infoDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Ask questions about any movie or TV show episode using advanced AI. Get insights about plot, characters, themes, trivia, and more - all powered by comprehensive TMDB data. {t('ai_settings.info_desc')}
</Text> </Text>
<View style={styles.featureList}> <View style={styles.featureList}>
<View style={styles.featureItem}> <View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> <MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Episode-specific context and analysis {t('ai_settings.feature_1')}
</Text> </Text>
</View> </View>
<View style={styles.featureItem}> <View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> <MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Plot explanations and character insights {t('ai_settings.feature_2')}
</Text> </Text>
</View> </View>
<View style={styles.featureItem}> <View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> <MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Behind-the-scenes trivia and facts {t('ai_settings.feature_3')}
</Text> </Text>
</View> </View>
<View style={styles.featureItem}> <View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> <MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Your own free OpenRouter API key {t('ai_settings.feature_4')}
</Text> </Text>
</View> </View>
</View> </View>
@ -222,21 +224,21 @@ const AISettingsScreen: React.FC = () => {
{/* API Key Configuration */} {/* API Key Configuration */}
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
OPENROUTER API KEY {t('ai_settings.api_key_section')}
</Text> </Text>
<View style={styles.apiKeySection}> <View style={styles.apiKeySection}>
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
API Key {t('ai_settings.api_key_label')}
</Text> </Text>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
Enter your OpenRouter API key to enable AI chat features {t('ai_settings.api_key_desc')}
</Text> </Text>
<TextInput <TextInput
style={[ style={[
styles.input, styles.input,
{ {
backgroundColor: currentTheme.colors.elevation2, backgroundColor: currentTheme.colors.elevation2,
color: currentTheme.colors.highEmphasis, color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.elevation2 borderColor: currentTheme.colors.elevation2
@ -258,14 +260,14 @@ const AISettingsScreen: React.FC = () => {
onPress={handleSaveApiKey} onPress={handleSaveApiKey}
disabled={loading} disabled={loading}
> >
<MaterialIcons <MaterialIcons
name="save" name="save"
size={20} size={20}
color={currentTheme.colors.white} color={currentTheme.colors.white}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={styles.saveButtonText}> <Text style={styles.saveButtonText}>
{loading ? 'Saving...' : 'Save API Key'} {loading ? t('ai_settings.saving') : t('ai_settings.save_api_key')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
@ -275,27 +277,27 @@ const AISettingsScreen: React.FC = () => {
onPress={handleSaveApiKey} onPress={handleSaveApiKey}
disabled={loading} disabled={loading}
> >
<MaterialIcons <MaterialIcons
name="update" name="update"
size={20} size={20}
color={currentTheme.colors.white} color={currentTheme.colors.white}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={styles.updateButtonText}>Update</Text> <Text style={styles.updateButtonText}>{t('ai_settings.update')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.removeButton, { borderColor: currentTheme.colors.error }]} style={[styles.removeButton, { borderColor: currentTheme.colors.error }]}
onPress={handleRemoveApiKey} onPress={handleRemoveApiKey}
> >
<MaterialIcons <MaterialIcons
name="delete" name="delete"
size={20} size={20}
color={currentTheme.colors.error} color={currentTheme.colors.error}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={[styles.removeButtonText, { color: currentTheme.colors.error }]}> <Text style={[styles.removeButtonText, { color: currentTheme.colors.error }]}>
Remove {t('ai_settings.remove')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -306,23 +308,23 @@ const AISettingsScreen: React.FC = () => {
style={[styles.getKeyButton, { backgroundColor: currentTheme.colors.elevation2 }]} style={[styles.getKeyButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={handleGetApiKey} onPress={handleGetApiKey}
> >
<MaterialIcons <MaterialIcons
name="open-in-new" name="open-in-new"
size={20} size={20}
color={currentTheme.colors.primary} color={currentTheme.colors.primary}
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
/> />
<Text style={[styles.getKeyButtonText, { color: currentTheme.colors.primary }]}> <Text style={[styles.getKeyButtonText, { color: currentTheme.colors.primary }]}>
Get Free API Key from OpenRouter {t('ai_settings.get_free_key')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
{/* Enable Toggle (top) */} {/* Enable Toggle (top) */}
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>Enable AI Chat</Text> <Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>{t('ai_settings.enable_chat')}</Text>
<Switch <Switch
value={!!settings.aiChatEnabled} value={!!settings.aiChatEnabled}
onValueChange={(v) => updateSetting('aiChatEnabled', v)} onValueChange={(v) => updateSetting('aiChatEnabled', v)}
@ -331,24 +333,24 @@ const AISettingsScreen: React.FC = () => {
ios_backgroundColor={currentTheme.colors.elevation2} ios_backgroundColor={currentTheme.colors.elevation2}
/> />
</View> </View>
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis, marginTop: 8 }]}>When enabled, the Ask AI button will appear on content pages.</Text> <Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis, marginTop: 8 }]}>{t('ai_settings.enable_chat_desc')}</Text>
</View> </View>
{/* Status Card */} {/* Status Card */}
{isKeySet && ( {isKeySet && (
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.statusHeader}> <View style={styles.statusHeader}>
<MaterialIcons <MaterialIcons
name="check-circle" name="check-circle"
size={24} size={24}
color={currentTheme.colors.success || '#4CAF50'} color={currentTheme.colors.success || '#4CAF50'}
/> />
<Text style={[styles.statusTitle, { color: currentTheme.colors.success || '#4CAF50' }]}> <Text style={[styles.statusTitle, { color: currentTheme.colors.success || '#4CAF50' }]}>
AI Chat Enabled {t('ai_settings.chat_enabled')}
</Text> </Text>
</View> </View>
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
You can now ask questions about movies and TV shows. Look for the "Ask AI" button on content pages! {t('ai_settings.chat_enabled_desc')}
</Text> </Text>
</View> </View>
)} )}
@ -356,14 +358,10 @@ const AISettingsScreen: React.FC = () => {
{/* Usage Info */} {/* Usage Info */}
<View style={[styles.usageCard, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.usageCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.usageTitle, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.usageTitle, { color: currentTheme.colors.highEmphasis }]}>
How it works {t('ai_settings.how_it_works')}
</Text> </Text>
<Text style={[styles.usageText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.usageText, { color: currentTheme.colors.mediumEmphasis }]}>
OpenRouter provides access to multiple AI models{'\n'} {t('ai_settings.how_it_works_desc')}
Your API key stays private and secure{'\n'}
Free tier includes generous usage limits{'\n'}
Chat with context about specific episodes/movies{'\n'}
Get detailed analysis and explanations
</Text> </Text>
</View> </View>
{/* OpenRouter branding */} {/* OpenRouter branding */}

View file

@ -30,6 +30,7 @@ import { logger } from '../utils/logger';
import { mmkvStorage } from '../services/mmkvStorage'; import { mmkvStorage } from '../services/mmkvStorage';
import { BlurView as ExpoBlurView } from 'expo-blur'; import { BlurView as ExpoBlurView } from 'expo-blur';
import CustomAlert from '../components/CustomAlert'; import CustomAlert from '../components/CustomAlert';
import { useTranslation } from 'react-i18next';
// Optional iOS Glass effect (expo-glass-effect) with safe fallback for AddonsScreen // Optional iOS Glass effect (expo-glass-effect) with safe fallback for AddonsScreen
let GlassViewComp: any = null; let GlassViewComp: any = null;
@ -536,6 +537,7 @@ const createStyles = (colors: any) => StyleSheet.create({
const AddonsScreen = () => { const AddonsScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const [addons, setAddons] = useState<ExtendedManifest[]>([]); const [addons, setAddons] = useState<ExtendedManifest[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -603,9 +605,9 @@ const AddonsScreen = () => {
} }
} catch (error) { } catch (error) {
logger.error('Failed to load addons:', error); logger.error('Failed to load addons:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Failed to load addons'); setAlertMessage(t('addons.load_error'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setLoading(false); setLoading(false);
@ -617,9 +619,9 @@ const AddonsScreen = () => {
const handleAddAddon = async (url?: string) => { const handleAddAddon = async (url?: string) => {
let urlToInstall = url || addonUrl; let urlToInstall = url || addonUrl;
if (!urlToInstall) { if (!urlToInstall) {
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Please enter an addon URL'); setAlertMessage(t('addons.invalid_url'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
return; return;
} }
@ -637,9 +639,9 @@ const AddonsScreen = () => {
setShowConfirmModal(true); setShowConfirmModal(true);
} catch (error) { } catch (error) {
logger.error('Failed to fetch addon details:', error); logger.error('Failed to fetch addon details:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage(`Failed to fetch addon details from ${urlToInstall}`); setAlertMessage(`${t('addons.fetch_error')} ${urlToInstall}`);
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setInstalling(false); setInstalling(false);
@ -656,15 +658,15 @@ const AddonsScreen = () => {
setShowConfirmModal(false); setShowConfirmModal(false);
setAddonDetails(null); setAddonDetails(null);
loadAddons(); loadAddons();
setAlertTitle('Success'); setAlertTitle(t('common.success'));
setAlertMessage('Addon installed successfully'); setAlertMessage(t('addons.install_success'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} catch (error) { } catch (error) {
logger.error('Failed to install addon:', error); logger.error('Failed to install addon:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Failed to install addon'); setAlertMessage(t('addons.install_error'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setInstalling(false); setInstalling(false);
@ -691,12 +693,12 @@ const AddonsScreen = () => {
}; };
const handleRemoveAddon = (addon: ExtendedManifest) => { const handleRemoveAddon = (addon: ExtendedManifest) => {
setAlertTitle('Uninstall Addon'); setAlertTitle(t('addons.uninstall_title'));
setAlertMessage(`Are you sure you want to uninstall ${addon.name}?`); setAlertMessage(t('addons.uninstall_message', { name: addon.name }));
setAlertActions([ setAlertActions([
{ label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } }, { label: t('common.cancel'), onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } },
{ {
label: 'Uninstall', label: t('addons.uninstall_button'),
onPress: async () => { onPress: async () => {
await stremioService.removeAddon(addon.id); await stremioService.removeAddon(addon.id);
setAddons(prev => prev.filter(a => a.id !== addon.id)); setAddons(prev => prev.filter(a => a.id !== addon.id));
@ -804,9 +806,9 @@ const AddonsScreen = () => {
// If we couldn't determine a config URL, show an error // If we couldn't determine a config URL, show an error
if (!configUrl) { if (!configUrl) {
logger.error(`Failed to determine config URL for addon: ${addon.name}, ID: ${addon.id}`); logger.error(`Failed to determine config URL for addon: ${addon.name}, ID: ${addon.id}`);
setAlertTitle('Configuration Unavailable'); setAlertTitle(t('addons.config_unavailable_title'));
setAlertMessage('Could not determine configuration URL for this addon.'); setAlertMessage(t('addons.config_unavailable_msg'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
return; return;
} }
@ -820,16 +822,16 @@ const AddonsScreen = () => {
Linking.openURL(configUrl); Linking.openURL(configUrl);
} else { } else {
logger.error(`URL cannot be opened: ${configUrl}`); logger.error(`URL cannot be opened: ${configUrl}`);
setAlertTitle('Cannot Open Configuration'); setAlertTitle(t('addons.cannot_open_config_title'));
setAlertMessage(`The configuration URL (${configUrl}) cannot be opened. The addon may not have a configuration page.`); setAlertMessage(t('addons.cannot_open_config_msg', { url: configUrl }));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} }
}).catch(err => { }).catch(err => {
logger.error(`Error checking if URL can be opened: ${configUrl}`, err); logger.error(`Error checking if URL can be opened: ${configUrl}`, err);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Could not open configuration page.'); setAlertMessage(t('addons.cannot_open_config_msg', { url: configUrl }));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
}); });
}; };
@ -851,7 +853,7 @@ const AddonsScreen = () => {
// Format the types into a simple category text // Format the types into a simple category text
const categoryText = types.length > 0 const categoryText = types.length > 0
? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') ? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'No categories'; : t('addons.no_categories');
const isFirstItem = index === 0; const isFirstItem = index === 0;
const isLastItem = index === addons.length - 1; const isLastItem = index === addons.length - 1;
@ -902,12 +904,12 @@ const AddonsScreen = () => {
<Text style={styles.addonName}>{item.name}</Text> <Text style={styles.addonName}>{item.name}</Text>
{isPreInstalled && ( {isPreInstalled && (
<View style={[styles.priorityBadge, { marginLeft: 8, backgroundColor: colors.success }]}> <View style={[styles.priorityBadge, { marginLeft: 8, backgroundColor: colors.success }]}>
<Text style={[styles.priorityText, { fontSize: 10 }]}>PRE-INSTALLED</Text> <Text style={[styles.priorityText, { fontSize: 10 }]}>{t('addons.pre_installed')}</Text>
</View> </View>
)} )}
</View> </View>
<View style={styles.addonMetaContainer}> <View style={styles.addonMetaContainer}>
<Text style={styles.addonVersion}>v{item.version || '1.0.0'}</Text> <Text style={styles.addonVersion}>{t('addons.version', { version: item.version || '1.0.0' })}</Text>
<Text style={styles.addonDot}></Text> <Text style={styles.addonDot}></Text>
<Text style={styles.addonCategory}>{categoryText}</Text> <Text style={styles.addonCategory}>{categoryText}</Text>
</View> </View>
@ -965,7 +967,7 @@ const AddonsScreen = () => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="chevron-left" size={28} color={colors.white} /> <MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Settings</Text> <Text style={styles.backText}>{t('settings.settings_title')}</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerActions}> <View style={styles.headerActions}>
@ -997,15 +999,15 @@ const AddonsScreen = () => {
</View> </View>
<Text style={styles.headerTitle}> <Text style={styles.headerTitle}>
Addons {t('addons.title')}
{reorderMode && <Text style={styles.reorderModeText}> (Reorder Mode)</Text>} {reorderMode && <Text style={styles.reorderModeText}>{t('addons.reorder_mode')}</Text>}
</Text> </Text>
{reorderMode && ( {reorderMode && (
<View style={styles.reorderInfoBanner}> <View style={styles.reorderInfoBanner}>
<MaterialIcons name="info-outline" size={18} color={colors.primary} /> <MaterialIcons name="info-outline" size={18} color={colors.primary} />
<Text style={styles.reorderInfoText}> <Text style={styles.reorderInfoText}>
Addons at the top have higher priority when loading content {t('addons.reorder_info')}
</Text> </Text>
</View> </View>
)} )}
@ -1023,24 +1025,24 @@ const AddonsScreen = () => {
{/* Overview Section */} {/* Overview Section */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>OVERVIEW</Text> <Text style={styles.sectionTitle}>{t('addons.overview')}</Text>
<View style={styles.statsContainer}> <View style={styles.statsContainer}>
<StatsCard value={addons.length} label="Addons" /> <StatsCard value={addons.length} label={t('addons.title')} />
<View style={styles.statsDivider} /> <View style={styles.statsDivider} />
<StatsCard value={addons.length} label="Active" /> <StatsCard value={addons.length} label={t('settings.items.active')} />
<View style={styles.statsDivider} /> <View style={styles.statsDivider} />
<StatsCard value={catalogCount} label="Catalogs" /> <StatsCard value={catalogCount} label={t('settings.items.catalogs')} />
</View> </View>
</View> </View>
{/* Hide Add Addon Section in reorder mode */} {/* Hide Add Addon Section in reorder mode */}
{!reorderMode && ( {!reorderMode && (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>ADD NEW ADDON</Text> <Text style={styles.sectionTitle}>{t('addons.add_button').toUpperCase()}</Text>
<View style={styles.addAddonContainer}> <View style={styles.addAddonContainer}>
<TextInput <TextInput
style={styles.addonInput} style={styles.addonInput}
placeholder="Addon URL" placeholder={t('addons.add_addon_placeholder')}
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
value={addonUrl} value={addonUrl}
onChangeText={setAddonUrl} onChangeText={setAddonUrl}
@ -1053,7 +1055,7 @@ const AddonsScreen = () => {
disabled={installing || !addonUrl} disabled={installing || !addonUrl}
> >
<Text style={styles.addButtonText}> <Text style={styles.addButtonText}>
{installing ? 'Loading...' : 'Add Addon'} {installing ? t('common.loading') : t('addons.add_button')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -1063,13 +1065,13 @@ const AddonsScreen = () => {
{/* Installed Addons Section */} {/* Installed Addons Section */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}> <Text style={styles.sectionTitle}>
{reorderMode ? "DRAG ADDONS TO REORDER" : "INSTALLED ADDONS"} {reorderMode ? t('addons.reorder_drag_title') : t('addons.installed_addons')}
</Text> </Text>
<View style={styles.addonList}> <View style={styles.addonList}>
{addons.length === 0 ? ( {addons.length === 0 ? (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<MaterialIcons name="extension-off" size={32} color={colors.mediumGray} /> <MaterialIcons name="extension-off" size={32} color={colors.mediumGray} />
<Text style={styles.emptyText}>No addons installed</Text> <Text style={styles.emptyText}>{t('addons.no_addons')}</Text>
</View> </View>
) : ( ) : (
addons.map((addon, index) => ( addons.map((addon, index) => (
@ -1083,7 +1085,8 @@ const AddonsScreen = () => {
)} )}
</View> </View>
</View> </View>
</ScrollView>
</ScrollView >
)} )}
{/* Addon Details Confirmation Modal */} {/* Addon Details Confirmation Modal */}
@ -1112,7 +1115,7 @@ const AddonsScreen = () => {
{addonDetails && ( {addonDetails && (
<> <>
<View style={styles.modalHeader}> <View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Install Addon</Text> <Text style={styles.modalTitle}>{t('addons.install')}</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => { onPress={() => {
setShowConfirmModal(false); setShowConfirmModal(false);
@ -1142,19 +1145,19 @@ const AddonsScreen = () => {
</View> </View>
)} )}
<Text style={styles.addonDetailName}>{addonDetails.name}</Text> <Text style={styles.addonDetailName}>{addonDetails.name}</Text>
<Text style={styles.addonDetailVersion}>v{addonDetails.version || '1.0.0'}</Text> <Text style={styles.addonDetailVersion}>{t('addons.version', { version: addonDetails.version || '1.0.0' })}</Text>
</View> </View>
<View style={styles.addonDetailSection}> <View style={styles.addonDetailSection}>
<Text style={styles.addonDetailSectionTitle}>Description</Text> <Text style={styles.addonDetailSectionTitle}>{t('addons.description')}</Text>
<Text style={styles.addonDetailDescription}> <Text style={styles.addonDetailDescription}>
{addonDetails.description || 'No description available'} {addonDetails.description || t('addons.no_description')}
</Text> </Text>
</View> </View>
{addonDetails.types && addonDetails.types.length > 0 && ( {addonDetails.types && addonDetails.types.length > 0 && (
<View style={styles.addonDetailSection}> <View style={styles.addonDetailSection}>
<Text style={styles.addonDetailSectionTitle}>Supported Types</Text> <Text style={styles.addonDetailSectionTitle}>{t('addons.supported_types')}</Text>
<View style={styles.addonDetailChips}> <View style={styles.addonDetailChips}>
{addonDetails.types.map((type, index) => ( {addonDetails.types.map((type, index) => (
<View key={index} style={styles.addonDetailChip}> <View key={index} style={styles.addonDetailChip}>
@ -1167,7 +1170,7 @@ const AddonsScreen = () => {
{addonDetails.catalogs && addonDetails.catalogs.length > 0 && ( {addonDetails.catalogs && addonDetails.catalogs.length > 0 && (
<View style={styles.addonDetailSection}> <View style={styles.addonDetailSection}>
<Text style={styles.addonDetailSectionTitle}>Catalogs</Text> <Text style={styles.addonDetailSectionTitle}>{t('addons.catalogs')}</Text>
<View style={styles.addonDetailChips}> <View style={styles.addonDetailChips}>
{addonDetails.catalogs.map((catalog, index) => ( {addonDetails.catalogs.map((catalog, index) => (
<View key={index} style={styles.addonDetailChip}> <View key={index} style={styles.addonDetailChip}>
@ -1189,7 +1192,7 @@ const AddonsScreen = () => {
setAddonDetails(null); setAddonDetails(null);
}} }}
> >
<Text style={styles.modalButtonText}>Cancel</Text> <Text style={styles.modalButtonText}>{t('common.cancel')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.modalButton, styles.installButton]} style={[styles.modalButton, styles.installButton]}
@ -1199,7 +1202,7 @@ const AddonsScreen = () => {
{installing ? ( {installing ? (
<ActivityIndicator size="small" color={colors.white} /> <ActivityIndicator size="small" color={colors.white} />
) : ( ) : (
<Text style={styles.modalButtonText}>Install</Text> <Text style={styles.modalButtonText}>{t('addons.install')}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -1216,7 +1219,7 @@ const AddonsScreen = () => {
onClose={() => setAlertVisible(false)} onClose={() => setAlertVisible(false)}
actions={alertActions} actions={alertActions}
/> />
</SafeAreaView> </SafeAreaView >
); );
}; };

View file

@ -23,12 +23,14 @@ import { useTheme } from '../contexts/ThemeContext';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import CustomAlert from '../components/CustomAlert'; import CustomAlert from '../components/CustomAlert';
import { useBackupOptions } from '../hooks/useBackupOptions'; import { useBackupOptions } from '../hooks/useBackupOptions';
import { useTranslation } from 'react-i18next';
const BackupScreen: React.FC = () => { const BackupScreen: React.FC = () => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const navigation = useNavigation(); const navigation = useNavigation();
const { preferences, updatePreference, getBackupOptions } = useBackupOptions(); const { preferences, updatePreference, getBackupOptions } = useBackupOptions();
const { t } = useTranslation();
// Collapsible sections state // Collapsible sections state
const [expandedSections, setExpandedSections] = useState({ const [expandedSections, setExpandedSections] = useState({
@ -60,7 +62,7 @@ const BackupScreen: React.FC = () => {
) => { ) => {
setAlertTitle(title); setAlertTitle(title);
setAlertMessage(message); setAlertMessage(message);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]); setAlertActions(actions && actions.length > 0 ? actions : [{ label: t('common.ok'), onPress: () => { } }]);
setAlertVisible(true); setAlertVisible(true);
}; };
@ -71,9 +73,9 @@ const BackupScreen: React.FC = () => {
logger.error('[BackupScreen] Failed to restart app:', error); logger.error('[BackupScreen] Failed to restart app:', error);
// Fallback: show error message // Fallback: show error message
openAlert( openAlert(
'Restart Failed', t('backup.alert_restart_failed_title'),
'Failed to restart the app. Please manually close and reopen the app to see your restored data.', t('backup.alert_restart_failed_msg'),
[{ label: 'OK', onPress: () => { } }] [{ label: t('common.ok'), onPress: () => { } }]
); );
} }
}; };
@ -128,12 +130,12 @@ const BackupScreen: React.FC = () => {
let total = 0; let total = 0;
if (preferences.includeLibrary) { if (preferences.includeLibrary) {
items.push(`Library: ${preview.library} items`); items.push(`${t('backup.library_label')}: ${preview.library} items`);
total += preview.library; total += preview.library;
} }
if (preferences.includeWatchProgress) { if (preferences.includeWatchProgress) {
items.push(`Watch Progress: ${preview.watchProgress} entries`); items.push(`${t('backup.watch_progress_label')}: ${preview.watchProgress} entries`);
total += preview.watchProgress; total += preview.watchProgress;
// Include watched status with watch progress // Include watched status with watch progress
items.push(`Watched Status: ${preview.watchedStatus} items`); items.push(`Watched Status: ${preview.watchedStatus} items`);
@ -141,28 +143,28 @@ const BackupScreen: React.FC = () => {
} }
if (preferences.includeAddons) { if (preferences.includeAddons) {
items.push(`Addons: ${preview.addons} installed`); items.push(`${t('backup.addons_label')}: ${preview.addons} installed`);
total += preview.addons; total += preview.addons;
} }
if (preferences.includeLocalScrapers) { if (preferences.includeLocalScrapers) {
items.push(`Plugins: ${preview.scrapers} configurations`); items.push(`${t('backup.plugins_label')}: ${preview.scrapers} configurations`);
total += preview.scrapers; total += preview.scrapers;
} }
// Check if no items are selected // Check if no items are selected
const message = items.length > 0 const message = items.length > 0
? `Backup Contents:\n\n${items.join('\n')}\n\nTotal: ${total} items\n\nThis backup includes your selected app settings, themes, watched markers, and integration data.` ? `Backup Contents:\n\n${items.join('\n')}\n\nTotal: ${total} items\n\nThis backup includes your selected app settings, themes, watched markers, and integration data.`
: `No content selected for backup.\n\nPlease enable at least one option in the Backup Options section above.`; : t('backup.alert_no_content');
openAlert( openAlert(
'Create Backup', t('backup.alert_create_title'),
message, message,
items.length > 0 items.length > 0
? [ ? [
{ label: 'Cancel', onPress: () => { } }, { label: t('common.cancel'), onPress: () => { } },
{ {
label: 'Create Backup', label: t('backup.action_create'),
onPress: async () => { onPress: async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -180,16 +182,16 @@ const BackupScreen: React.FC = () => {
} }
openAlert( openAlert(
'Backup Created', t('backup.alert_backup_created_title'),
'Your backup has been created and is ready to share.', t('backup.alert_backup_created_msg'),
[{ label: 'OK', onPress: () => { } }] [{ label: t('common.ok'), onPress: () => { } }]
); );
} catch (error) { } catch (error) {
logger.error('[BackupScreen] Failed to create backup:', error); logger.error('[BackupScreen] Failed to create backup:', error);
openAlert( openAlert(
'Backup Failed', t('backup.alert_backup_failed_title'),
`Failed to create backup: ${error instanceof Error ? error.message : String(error)}`, `Failed to create backup: ${error instanceof Error ? error.message : String(error)}`,
[{ label: 'OK', onPress: () => { } }] [{ label: t('common.ok'), onPress: () => { } }]
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -197,18 +199,18 @@ const BackupScreen: React.FC = () => {
} }
} }
] ]
: [{ label: 'OK', onPress: () => { } }] : [{ label: t('common.ok'), onPress: () => { } }]
); );
} catch (error) { } catch (error) {
logger.error('[BackupScreen] Failed to get backup preview:', error); logger.error('[BackupScreen] Failed to get backup preview:', error);
openAlert( openAlert(
'Error', t('common.error'),
'Failed to prepare backup information. Please try again.', 'Failed to prepare backup information. Please try again.',
[{ label: 'OK', onPress: () => { } }] [{ label: t('common.ok'), onPress: () => { } }]
); );
setIsLoading(false); setIsLoading(false);
} }
}, [openAlert, preferences, getBackupOptions]); }, [openAlert, preferences, getBackupOptions, t]);
// Restore backup // Restore backup
const handleRestoreBackup = useCallback(async () => { const handleRestoreBackup = useCallback(async () => {
@ -228,10 +230,12 @@ const BackupScreen: React.FC = () => {
const backupInfo = await backupService.getBackupInfo(fileUri); const backupInfo = await backupService.getBackupInfo(fileUri);
openAlert( openAlert(
'Confirm Restore', t('backup.alert_restore_confirm_title'),
`This will restore your data from a backup created on ${new Date(backupInfo.timestamp || 0).toLocaleDateString()}.\n\nThis action will overwrite your current data. Are you sure you want to continue?`, t('backup.alert_restore_confirm_msg', {
date: new Date(backupInfo.timestamp || 0).toLocaleDateString()
}),
[ [
{ label: 'Cancel', onPress: () => { } }, { label: t('common.cancel'), onPress: () => { } },
{ {
label: 'Restore', label: 'Restore',
onPress: async () => { onPress: async () => {
@ -243,12 +247,12 @@ const BackupScreen: React.FC = () => {
await backupService.restoreBackup(fileUri, restoreOptions); await backupService.restoreBackup(fileUri, restoreOptions);
openAlert( openAlert(
'Restore Complete', t('backup.alert_restore_complete_title'),
'Your data has been successfully restored. Please restart the app to see all changes.', t('backup.alert_restore_complete_msg'),
[ [
{ label: 'Cancel', onPress: () => { } }, { label: t('common.cancel'), onPress: () => { } },
{ {
label: 'Restart App', label: t('backup.restart_app'),
onPress: restartApp, onPress: restartApp,
style: { fontWeight: 'bold' } style: { fontWeight: 'bold' }
} }
@ -257,9 +261,9 @@ const BackupScreen: React.FC = () => {
} catch (error) { } catch (error) {
logger.error('[BackupScreen] Failed to restore backup:', error); logger.error('[BackupScreen] Failed to restore backup:', error);
openAlert( openAlert(
'Restore Failed', t('backup.alert_restore_failed_title'),
`Failed to restore backup: ${error instanceof Error ? error.message : String(error)}`, `Failed to restore backup: ${error instanceof Error ? error.message : String(error)}`,
[{ label: 'OK', onPress: () => { } }] [{ label: t('common.ok'), onPress: () => { } }]
); );
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -273,10 +277,10 @@ const BackupScreen: React.FC = () => {
openAlert( openAlert(
'File Selection Failed', 'File Selection Failed',
`Failed to select backup file: ${error instanceof Error ? error.message : String(error)}`, `Failed to select backup file: ${error instanceof Error ? error.message : String(error)}`,
[{ label: 'OK', onPress: () => { } }] [{ label: t('common.ok'), onPress: () => { } }]
); );
} }
}, [openAlert]); }, [openAlert, t]);
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
@ -289,7 +293,7 @@ const BackupScreen: React.FC = () => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.white} /> <MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.white} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text> <Text style={[styles.backText, { color: currentTheme.colors.primary }]}>{t('settings.settings_title')}</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerActions}> <View style={styles.headerActions}>
@ -298,7 +302,7 @@ const BackupScreen: React.FC = () => {
</View> </View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}> <Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>
Backup & Restore {t('backup.title')}
</Text> </Text>
{/* Content */} {/* Content */}
@ -319,10 +323,10 @@ const BackupScreen: React.FC = () => {
{/* Backup Options Section */} {/* Backup Options Section */}
<View style={[styles.section, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.section, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
Backup Options {t('backup.options_title')}
</Text> </Text>
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Choose what to include in your backups {t('backup.options_desc')}
</Text> </Text>
{/* Core Data Group */} {/* Core Data Group */}
@ -332,7 +336,7 @@ const BackupScreen: React.FC = () => {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.groupLabel, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.groupLabel, { color: currentTheme.colors.highEmphasis }]}>
Core Data {t('backup.section_core')}
</Text> </Text>
<Animated.View <Animated.View
style={{ style={{
@ -358,15 +362,15 @@ const BackupScreen: React.FC = () => {
}} }}
> >
<OptionToggle <OptionToggle
label="Library" label={t('backup.library_label')}
description="Your saved movies and TV shows" description={t('backup.library_desc')}
value={preferences.includeLibrary} value={preferences.includeLibrary}
onValueChange={(v) => updatePreference('includeLibrary', v)} onValueChange={(v) => updatePreference('includeLibrary', v)}
theme={currentTheme} theme={currentTheme}
/> />
<OptionToggle <OptionToggle
label="Watch Progress" label={t('backup.watch_progress_label')}
description="Continue watching positions" description={t('backup.watch_progress_desc')}
value={preferences.includeWatchProgress} value={preferences.includeWatchProgress}
onValueChange={(v) => updatePreference('includeWatchProgress', v)} onValueChange={(v) => updatePreference('includeWatchProgress', v)}
theme={currentTheme} theme={currentTheme}
@ -380,7 +384,7 @@ const BackupScreen: React.FC = () => {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.groupLabel, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.groupLabel, { color: currentTheme.colors.highEmphasis }]}>
Addons & Integrations {t('backup.section_addons')}
</Text> </Text>
<Animated.View <Animated.View
style={{ style={{
@ -406,22 +410,22 @@ const BackupScreen: React.FC = () => {
}} }}
> >
<OptionToggle <OptionToggle
label="Addons" label={t('backup.addons_label')}
description="Installed Stremio addons" description={t('backup.addons_desc')}
value={preferences.includeAddons} value={preferences.includeAddons}
onValueChange={(v) => updatePreference('includeAddons', v)} onValueChange={(v) => updatePreference('includeAddons', v)}
theme={currentTheme} theme={currentTheme}
/> />
<OptionToggle <OptionToggle
label="Plugins" label={t('backup.plugins_label')}
description="Custom scraper configurations" description={t('backup.plugins_desc')}
value={preferences.includeLocalScrapers} value={preferences.includeLocalScrapers}
onValueChange={(v) => updatePreference('includeLocalScrapers', v)} onValueChange={(v) => updatePreference('includeLocalScrapers', v)}
theme={currentTheme} theme={currentTheme}
/> />
<OptionToggle <OptionToggle
label="Trakt Integration" label={t('backup.trakt_label')}
description="Sync data and authentication tokens" description={t('backup.trakt_desc')}
value={preferences.includeTraktData} value={preferences.includeTraktData}
onValueChange={(v) => updatePreference('includeTraktData', v)} onValueChange={(v) => updatePreference('includeTraktData', v)}
theme={currentTheme} theme={currentTheme}
@ -435,7 +439,7 @@ const BackupScreen: React.FC = () => {
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.groupLabel, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.groupLabel, { color: currentTheme.colors.highEmphasis }]}>
Settings & Preferences {t('backup.section_settings')}
</Text> </Text>
<Animated.View <Animated.View
style={{ style={{
@ -461,29 +465,29 @@ const BackupScreen: React.FC = () => {
}} }}
> >
<OptionToggle <OptionToggle
label="App Settings" label={t('backup.app_settings_label')}
description="Theme, preferences, and configurations" description={t('backup.app_settings_desc')}
value={preferences.includeSettings} value={preferences.includeSettings}
onValueChange={(v) => updatePreference('includeSettings', v)} onValueChange={(v) => updatePreference('includeSettings', v)}
theme={currentTheme} theme={currentTheme}
/> />
<OptionToggle <OptionToggle
label="User Preferences" label={t('backup.user_prefs_label')}
description="Addon order and UI settings" description={t('backup.user_prefs_desc')}
value={preferences.includeUserPreferences} value={preferences.includeUserPreferences}
onValueChange={(v) => updatePreference('includeUserPreferences', v)} onValueChange={(v) => updatePreference('includeUserPreferences', v)}
theme={currentTheme} theme={currentTheme}
/> />
<OptionToggle <OptionToggle
label="Catalog Settings" label={t('backup.catalog_settings_label')}
description="Catalog filters and preferences" description={t('backup.catalog_settings_desc')}
value={preferences.includeCatalogSettings} value={preferences.includeCatalogSettings}
onValueChange={(v) => updatePreference('includeCatalogSettings', v)} onValueChange={(v) => updatePreference('includeCatalogSettings', v)}
theme={currentTheme} theme={currentTheme}
/> />
<OptionToggle <OptionToggle
label="API Keys" label={t('backup.api_keys_label')}
description="MDBList and OpenRouter keys" description={t('backup.api_keys_desc')}
value={preferences.includeApiKeys} value={preferences.includeApiKeys}
onValueChange={(v) => updatePreference('includeApiKeys', v)} onValueChange={(v) => updatePreference('includeApiKeys', v)}
theme={currentTheme} theme={currentTheme}
@ -494,7 +498,7 @@ const BackupScreen: React.FC = () => {
{/* Backup Actions */} {/* Backup Actions */}
<View style={[styles.section, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.section, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
Backup & Restore {t('backup.title')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
@ -513,7 +517,7 @@ const BackupScreen: React.FC = () => {
) : ( ) : (
<> <>
<MaterialIcons name="backup" size={20} color="white" /> <MaterialIcons name="backup" size={20} color="white" />
<Text style={styles.actionButtonText}>Create Backup</Text> <Text style={styles.actionButtonText}>{t('backup.action_create')}</Text>
</> </>
)} )}
</TouchableOpacity> </TouchableOpacity>
@ -530,20 +534,17 @@ const BackupScreen: React.FC = () => {
disabled={isLoading} disabled={isLoading}
> >
<MaterialIcons name="restore" size={20} color="white" /> <MaterialIcons name="restore" size={20} color="white" />
<Text style={styles.actionButtonText}>Restore from Backup</Text> <Text style={styles.actionButtonText}>{t('backup.action_restore')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Info Section */} {/* Info Section */}
<View style={[styles.section, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.section, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
About Backups {t('backup.section_info')}
</Text> </Text>
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
Customize what gets backed up using the toggles above{'\n'} {t('backup.info_text')}
Backup files are stored locally on your device{'\n'}
Share your backup to transfer data between devices{'\n'}
Restoring will overwrite your current data
</Text> </Text>
</View> </View>
</View> </View>

View file

@ -16,6 +16,7 @@ import {
import { InteractionManager } from 'react-native'; import { InteractionManager } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
@ -55,6 +56,7 @@ interface CalendarSection {
} }
const CalendarScreen = () => { const CalendarScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { libraryItems, loading: libraryLoading } = useLibrary(); const { libraryItems, loading: libraryLoading } = useLibrary();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
@ -189,7 +191,7 @@ const CalendarScreen = () => {
) : ( ) : (
<> <>
<Text style={[styles.noEpisodesText, { color: currentTheme.colors.text }]}> <Text style={[styles.noEpisodesText, { color: currentTheme.colors.text }]}>
No scheduled episodes {t('calendar.no_scheduled_episodes')}
</Text> </Text>
<View style={styles.dateContainer}> <View style={styles.dateContainer}>
<MaterialIcons <MaterialIcons
@ -197,7 +199,7 @@ const CalendarScreen = () => {
size={16} size={16}
color={currentTheme.colors.lightGray} color={currentTheme.colors.lightGray}
/> />
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>Check back later</Text> <Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>{t('calendar.check_back_later')}</Text>
</View> </View>
</> </>
)} )}
@ -207,16 +209,28 @@ const CalendarScreen = () => {
); );
}; };
const renderSectionHeader = ({ section }: { section: CalendarSection }) => ( const renderSectionHeader = ({ section }: { section: CalendarSection }) => {
<View style={[styles.sectionHeader, { // Map section titles to translation keys
backgroundColor: currentTheme.colors.darkBackground, const titleKeyMap: Record<string, string> = {
borderBottomColor: currentTheme.colors.border 'This Week': 'home.this_week',
}]}> 'Upcoming': 'home.upcoming',
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}> 'Recently Released': 'home.recently_released',
{section.title} 'Series with No Scheduled Episodes': 'home.no_scheduled_episodes'
</Text> };
</View>
); const displayTitle = titleKeyMap[section.title] ? t(titleKeyMap[section.title]) : section.title;
return (
<View style={[styles.sectionHeader, {
backgroundColor: currentTheme.colors.darkBackground,
borderBottomColor: currentTheme.colors.border
}]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>
{displayTitle}
</Text>
</View>
);
};
// Process all episodes once data is loaded - using memory-efficient approach // Process all episodes once data is loaded - using memory-efficient approach
const allEpisodes = React.useMemo(() => { const allEpisodes = React.useMemo(() => {
@ -276,7 +290,7 @@ const CalendarScreen = () => {
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} /> <ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={styles.loadingText}>Loading calendar...</Text> <Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>{t('calendar.loading')}</Text>
</View> </View>
</SafeAreaView> </SafeAreaView>
); );
@ -293,14 +307,14 @@ const CalendarScreen = () => {
> >
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} /> <MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Calendar</Text> <Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>{t('calendar.title')}</Text>
<View style={{ width: 40 }} /> <View style={{ width: 40 }} />
</View> </View>
{selectedDate && filteredEpisodes.length > 0 && ( {selectedDate && filteredEpisodes.length > 0 && (
<View style={[styles.filterInfoContainer, { borderBottomColor: currentTheme.colors.border }]}> <View style={[styles.filterInfoContainer, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.filterInfoText, { color: currentTheme.colors.text }]}> <Text style={[styles.filterInfoText, { color: currentTheme.colors.text }]}>
Showing episodes for {format(selectedDate, 'MMMM d, yyyy')} {t('calendar.showing_episodes_for', { date: format(selectedDate, 'MMMM d, yyyy') })}
</Text> </Text>
<TouchableOpacity onPress={clearDateFilter} style={styles.clearFilterButton}> <TouchableOpacity onPress={clearDateFilter} style={styles.clearFilterButton}>
<MaterialIcons name="close" size={18} color={currentTheme.colors.text} /> <MaterialIcons name="close" size={18} color={currentTheme.colors.text} />
@ -337,14 +351,14 @@ const CalendarScreen = () => {
<View style={styles.emptyFilterContainer}> <View style={styles.emptyFilterContainer}>
<MaterialIcons name="event-busy" size={48} color={currentTheme.colors.lightGray} /> <MaterialIcons name="event-busy" size={48} color={currentTheme.colors.lightGray} />
<Text style={[styles.emptyFilterText, { color: currentTheme.colors.text }]}> <Text style={[styles.emptyFilterText, { color: currentTheme.colors.text }]}>
No episodes for {format(selectedDate, 'MMMM d, yyyy')} {t('calendar.no_episodes_for', { date: format(selectedDate, 'MMMM d, yyyy') })}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.clearFilterButtonLarge, { backgroundColor: currentTheme.colors.primary }]} style={[styles.clearFilterButtonLarge, { backgroundColor: currentTheme.colors.primary }]}
onPress={clearDateFilter} onPress={clearDateFilter}
> >
<Text style={[styles.clearFilterButtonText, { color: currentTheme.colors.text }]}> <Text style={[styles.clearFilterButtonText, { color: currentTheme.colors.white }]}>
Show All Episodes {t('calendar.show_all_episodes')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -373,10 +387,10 @@ const CalendarScreen = () => {
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<MaterialIcons name="calendar-today" size={64} color={currentTheme.colors.lightGray} /> <MaterialIcons name="calendar-today" size={64} color={currentTheme.colors.lightGray} />
<Text style={[styles.emptyText, { color: currentTheme.colors.text }]}> <Text style={[styles.emptyText, { color: currentTheme.colors.text }]}>
No upcoming episodes found {t('calendar.no_upcoming_found')}
</Text> </Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}> <Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Add series to your library to see their upcoming episodes here {t('calendar.add_series_desc')}
</Text> </Text>
</View> </View>
)} )}

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
View, View,
Text, Text,
@ -59,6 +60,7 @@ type CastMoviesScreenRouteProp = RouteProp<RootStackParamList, 'CastMovies'>;
const CastMoviesScreen: React.FC = () => { const CastMoviesScreen: React.FC = () => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const route = useRoute<CastMoviesScreenRouteProp>(); const route = useRoute<CastMoviesScreenRouteProp>();
const { castMember } = route.params; const { castMember } = route.params;
@ -89,27 +91,27 @@ const CastMoviesScreen: React.FC = () => {
const fetchCastCredits = async () => { const fetchCastCredits = async () => {
if (!castMember) return; if (!castMember) return;
setLoading(true); setLoading(true);
try { try {
const credits = await tmdbService.getPersonCombinedCredits(castMember.id); const credits = await tmdbService.getPersonCombinedCredits(castMember.id);
if (credits && credits.cast) { if (credits && credits.cast) {
const currentDate = new Date(); const currentDate = new Date();
// Combine cast roles with enhanced data, excluding talk shows and variety shows // Combine cast roles with enhanced data, excluding talk shows and variety shows
const allCredits = credits.cast const allCredits = credits.cast
.filter((item: any) => { .filter((item: any) => {
// Filter out talk shows, variety shows, and ensure we have required data // Filter out talk shows, variety shows, and ensure we have required data
const hasPoster = item.poster_path; const hasPoster = item.poster_path;
const hasReleaseDate = item.release_date || item.first_air_date; const hasReleaseDate = item.release_date || item.first_air_date;
if (!hasPoster || !hasReleaseDate) return false; if (!hasPoster || !hasReleaseDate) return false;
// Enhanced talk show filtering // Enhanced talk show filtering
const title = (item.title || item.name || '').toLowerCase(); const title = (item.title || item.name || '').toLowerCase();
const overview = (item.overview || '').toLowerCase(); const overview = (item.overview || '').toLowerCase();
// List of common talk show and variety show keywords // List of common talk show and variety show keywords
const talkShowKeywords = [ const talkShowKeywords = [
'talk', 'show', 'late night', 'tonight show', 'jimmy fallon', 'snl', 'saturday night live', 'talk', 'show', 'late night', 'tonight show', 'jimmy fallon', 'snl', 'saturday night live',
@ -120,18 +122,18 @@ const CastMoviesScreen: React.FC = () => {
'red carpet', 'premiere', 'after party', 'behind the scenes', 'making of', 'documentary', 'red carpet', 'premiere', 'after party', 'behind the scenes', 'making of', 'documentary',
'special', 'concert', 'live performance', 'mtv', 'vh1', 'bet', 'comedy', 'roast' 'special', 'concert', 'live performance', 'mtv', 'vh1', 'bet', 'comedy', 'roast'
]; ];
// Check if any keyword matches // Check if any keyword matches
const isTalkShow = talkShowKeywords.some(keyword => const isTalkShow = talkShowKeywords.some(keyword =>
title.includes(keyword) || overview.includes(keyword) title.includes(keyword) || overview.includes(keyword)
); );
return !isTalkShow; return !isTalkShow;
}) })
.map((item: any) => { .map((item: any) => {
const releaseDate = new Date(item.release_date || item.first_air_date); const releaseDate = new Date(item.release_date || item.first_air_date);
const isUpcoming = releaseDate > currentDate; const isUpcoming = releaseDate > currentDate;
return { return {
id: item.id, id: item.id,
title: item.title || item.name, title: item.title || item.name,
@ -144,7 +146,7 @@ const CastMoviesScreen: React.FC = () => {
isUpcoming, isUpcoming,
}; };
}); });
setMovies(allCredits); setMovies(allCredits);
} }
} catch (error) { } catch (error) {
@ -223,41 +225,41 @@ const CastMoviesScreen: React.FC = () => {
isUpcoming: movie.isUpcoming isUpcoming: movie.isUpcoming
}); });
} }
try { try {
if (__DEV__) console.log('Attempting to get Stremio ID for:', movie.media_type, movie.id.toString()); if (__DEV__) console.log('Attempting to get Stremio ID for:', movie.media_type, movie.id.toString());
// Get Stremio ID using catalogService // Get Stremio ID using catalogService
const stremioId = await catalogService.getStremioId(movie.media_type, movie.id.toString()); const stremioId = await catalogService.getStremioId(movie.media_type, movie.id.toString());
if (__DEV__) console.log('Stremio ID result:', stremioId); if (__DEV__) console.log('Stremio ID result:', stremioId);
if (stremioId) { if (stremioId) {
if (__DEV__) console.log('Successfully found Stremio ID, navigating to Metadata with:', { if (__DEV__) console.log('Successfully found Stremio ID, navigating to Metadata with:', {
id: stremioId, id: stremioId,
type: movie.media_type type: movie.media_type
}); });
// Convert TMDB media type to Stremio media type // Convert TMDB media type to Stremio media type
const stremioType = movie.media_type === 'tv' ? 'series' : movie.media_type; const stremioType = movie.media_type === 'tv' ? 'series' : movie.media_type;
if (__DEV__) console.log('Navigating with Stremio type conversion:', { if (__DEV__) console.log('Navigating with Stremio type conversion:', {
originalType: movie.media_type, originalType: movie.media_type,
stremioType: stremioType, stremioType: stremioType,
id: stremioId id: stremioId
}); });
navigation.dispatch( navigation.dispatch(
StackActions.push('Metadata', { StackActions.push('Metadata', {
id: stremioId, id: stremioId,
type: stremioType type: stremioType
}) })
); );
} else { } else {
if (__DEV__) console.warn('Stremio ID is null/undefined for movie:', movie.title); if (__DEV__) console.warn('Stremio ID is null/undefined for movie:', movie.title);
throw new Error('Could not find Stremio ID'); throw new Error('Could not find Stremio ID');
} }
} catch (error: any) { } catch (error: any) {
if (__DEV__) { if (__DEV__) {
console.error('=== Error in handleMoviePress ==='); console.error('=== Error in handleMoviePress ===');
console.error('Movie:', movie.title); console.error('Movie:', movie.title);
@ -265,9 +267,9 @@ const CastMoviesScreen: React.FC = () => {
console.error('Error message:', error.message); console.error('Error message:', error.message);
console.error('Error stack:', error.stack); console.error('Error stack:', error.stack);
} }
setAlertTitle('Error'); setAlertTitle(t('cast.alert_error_title'));
setAlertMessage(`Unable to load "${movie.title}". Please try again later.`); setAlertMessage(t('cast.alert_error_message', { title: movie.title }));
setAlertActions([{ label: 'OK', onPress: () => {} }]); setAlertActions([{ label: t('cast.alert_ok'), onPress: () => { } }]);
setAlertVisible(true); setAlertVisible(true);
} }
}; };
@ -278,7 +280,7 @@ const CastMoviesScreen: React.FC = () => {
const renderFilterButton = (filter: 'all' | 'movies' | 'tv', label: string, count: number) => { const renderFilterButton = (filter: 'all' | 'movies' | 'tv', label: string, count: number) => {
const isSelected = selectedFilter === filter; const isSelected = selectedFilter === filter;
return ( return (
<Animated.View entering={FadeIn.delay(100)}> <Animated.View entering={FadeIn.delay(100)}>
<TouchableOpacity <TouchableOpacity
@ -286,8 +288,8 @@ const CastMoviesScreen: React.FC = () => {
paddingHorizontal: 18, paddingHorizontal: 18,
paddingVertical: 10, paddingVertical: 10,
borderRadius: 25, borderRadius: 25,
backgroundColor: isSelected backgroundColor: isSelected
? currentTheme.colors.primary ? currentTheme.colors.primary
: 'rgba(255, 255, 255, 0.08)', : 'rgba(255, 255, 255, 0.08)',
marginRight: 12, marginRight: 12,
borderWidth: isSelected ? 0 : 1, borderWidth: isSelected ? 0 : 1,
@ -311,7 +313,7 @@ const CastMoviesScreen: React.FC = () => {
const renderSortButton = (sort: 'popularity' | 'latest' | 'upcoming', label: string, icon: string) => { const renderSortButton = (sort: 'popularity' | 'latest' | 'upcoming', label: string, icon: string) => {
const isSelected = sortBy === sort; const isSelected = sortBy === sort;
return ( return (
<Animated.View entering={FadeIn.delay(200)}> <Animated.View entering={FadeIn.delay(200)}>
<TouchableOpacity <TouchableOpacity
@ -319,8 +321,8 @@ const CastMoviesScreen: React.FC = () => {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 8, paddingVertical: 8,
borderRadius: 20, borderRadius: 20,
backgroundColor: isSelected backgroundColor: isSelected
? 'rgba(255, 255, 255, 0.15)' ? 'rgba(255, 255, 255, 0.15)'
: 'transparent', : 'transparent',
marginRight: 12, marginRight: 12,
flexDirection: 'row', flexDirection: 'row',
@ -329,10 +331,10 @@ const CastMoviesScreen: React.FC = () => {
onPress={() => setSortBy(sort)} onPress={() => setSortBy(sort)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<MaterialIcons <MaterialIcons
name={icon as any} name={icon as any}
size={16} size={16}
color={isSelected ? currentTheme.colors.primary : 'rgba(255, 255, 255, 0.6)'} color={isSelected ? currentTheme.colors.primary : 'rgba(255, 255, 255, 0.6)'}
style={{ marginRight: 6 }} style={{ marginRight: 6 }}
/> />
<Text style={{ <Text style={{
@ -397,7 +399,7 @@ const CastMoviesScreen: React.FC = () => {
<MaterialIcons name="movie" size={32} color="rgba(255, 255, 255, 0.2)" /> <MaterialIcons name="movie" size={32} color="rgba(255, 255, 255, 0.2)" />
</View> </View>
)} )}
{/* Upcoming indicator */} {/* Upcoming indicator */}
{item.isUpcoming && ( {item.isUpcoming && (
<View style={{ <View style={{
@ -419,7 +421,7 @@ const CastMoviesScreen: React.FC = () => {
marginLeft: 4, marginLeft: 4,
letterSpacing: 0.2, letterSpacing: 0.2,
}}> }}>
UPCOMING {t('cast.upcoming_badge')}
</Text> </Text>
</View> </View>
)} )}
@ -463,7 +465,7 @@ const CastMoviesScreen: React.FC = () => {
}} }}
/> />
</View> </View>
<View style={{ paddingHorizontal: 4, marginTop: 8 }}> <View style={{ paddingHorizontal: 4, marginTop: 8 }}>
<Text style={{ <Text style={{
color: '#fff', color: '#fff',
@ -474,7 +476,7 @@ const CastMoviesScreen: React.FC = () => {
}} numberOfLines={2}> }} numberOfLines={2}>
{`${item.title}`} {`${item.title}`}
</Text> </Text>
{item.character && ( {item.character && (
<Text style={{ <Text style={{
color: 'rgba(255, 255, 255, 0.65)', color: 'rgba(255, 255, 255, 0.65)',
@ -482,10 +484,10 @@ const CastMoviesScreen: React.FC = () => {
marginTop: 3, marginTop: 3,
fontWeight: '500', fontWeight: '500',
}} numberOfLines={1}> }} numberOfLines={1}>
{`as ${item.character}`} {t('cast.as_character', { character: item.character })}
</Text> </Text>
)} )}
<View style={{ <View style={{
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -502,7 +504,7 @@ const CastMoviesScreen: React.FC = () => {
{`${new Date(item.release_date).getFullYear()}`} {`${new Date(item.release_date).getFullYear()}`}
</Text> </Text>
)} )}
{item.isUpcoming && ( {item.isUpcoming && (
<View style={{ <View style={{
flexDirection: 'row', flexDirection: 'row',
@ -516,7 +518,7 @@ const CastMoviesScreen: React.FC = () => {
marginLeft: 2, marginLeft: 2,
letterSpacing: 0.2, letterSpacing: 0.2,
}}> }}>
Coming Soon {t('cast.coming_soon')}
</Text> </Text>
</View> </View>
)} )}
@ -538,7 +540,7 @@ const CastMoviesScreen: React.FC = () => {
[1, 0.9], [1, 0.9],
Extrapolate.CLAMP Extrapolate.CLAMP
); );
return { return {
opacity, opacity,
}; };
@ -547,7 +549,7 @@ const CastMoviesScreen: React.FC = () => {
return ( return (
<View style={{ flex: 1, backgroundColor: currentTheme.colors.darkBackground }}> <View style={{ flex: 1, backgroundColor: currentTheme.colors.darkBackground }}>
{/* Minimal Header */} {/* Minimal Header */}
<Animated.View <Animated.View
style={[ style={[
{ {
paddingTop: safeAreaTop + 16, paddingTop: safeAreaTop + 16,
@ -560,7 +562,7 @@ const CastMoviesScreen: React.FC = () => {
headerAnimatedStyle headerAnimatedStyle
]} ]}
> >
<Animated.View <Animated.View
entering={SlideInDown.delay(100)} entering={SlideInDown.delay(100)}
style={{ flexDirection: 'row', alignItems: 'center' }} style={{ flexDirection: 'row', alignItems: 'center' }}
> >
@ -579,7 +581,7 @@ const CastMoviesScreen: React.FC = () => {
> >
<MaterialIcons name="arrow-back" size={20} color="rgba(255, 255, 255, 0.9)" /> <MaterialIcons name="arrow-back" size={20} color="rgba(255, 255, 255, 0.9)" />
</TouchableOpacity> </TouchableOpacity>
<View style={{ <View style={{
width: 44, width: 44,
height: 44, height: 44,
@ -613,7 +615,7 @@ const CastMoviesScreen: React.FC = () => {
</View> </View>
)} )}
</View> </View>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<Text style={{ <Text style={{
color: '#fff', color: '#fff',
@ -630,7 +632,7 @@ const CastMoviesScreen: React.FC = () => {
fontWeight: '500', fontWeight: '500',
letterSpacing: 0.2, letterSpacing: 0.2,
}}> }}>
{`Filmography • ${movies.length} titles`} {t('cast.filmography_count', { count: movies.length })}
</Text> </Text>
</View> </View>
</Animated.View> </Animated.View>
@ -652,16 +654,16 @@ const CastMoviesScreen: React.FC = () => {
letterSpacing: 0.5, letterSpacing: 0.5,
textTransform: 'uppercase', textTransform: 'uppercase',
}}> }}>
Filter {t('cast.filter')}
</Text> </Text>
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingRight: 20 }} contentContainerStyle={{ paddingRight: 20 }}
> >
{renderFilterButton('all', 'All', movies.length)} {renderFilterButton('all', t('catalog.all'), movies.length)}
{renderFilterButton('movies', 'Movies', movieCount)} {renderFilterButton('movies', t('catalog.movies'), movieCount)}
{renderFilterButton('tv', 'TV Shows', tvCount)} {renderFilterButton('tv', t('catalog.tv_shows'), tvCount)}
</ScrollView> </ScrollView>
</View> </View>
@ -675,16 +677,16 @@ const CastMoviesScreen: React.FC = () => {
letterSpacing: 0.5, letterSpacing: 0.5,
textTransform: 'uppercase', textTransform: 'uppercase',
}}> }}>
Sort By {t('cast.sort_by')}
</Text> </Text>
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={{ paddingRight: 20 }} contentContainerStyle={{ paddingRight: 20 }}
> >
{renderSortButton('popularity', 'Popular', 'trending-up')} {renderSortButton('popularity', t('cast.sort_popular'), 'trending-up')}
{renderSortButton('latest', 'Latest', 'schedule')} {renderSortButton('latest', t('cast.sort_latest'), 'schedule')}
{renderSortButton('upcoming', 'Upcoming', 'event')} {renderSortButton('upcoming', t('cast.sort_upcoming'), 'event')}
</ScrollView> </ScrollView>
</View> </View>
</View> </View>
@ -703,7 +705,7 @@ const CastMoviesScreen: React.FC = () => {
marginTop: 12, marginTop: 12,
fontWeight: '500', fontWeight: '500',
}}> }}>
Loading filmography... {t('cast.loading_filmography')}
</Text> </Text>
</View> </View>
) : ( ) : (
@ -755,7 +757,7 @@ const CastMoviesScreen: React.FC = () => {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
}}> }}>
{`Load More (${filteredAndSortedMovies.length - displayLimit} remaining)`} {t('cast.load_more_remaining', { count: filteredAndSortedMovies.length - displayLimit })}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@ -763,7 +765,7 @@ const CastMoviesScreen: React.FC = () => {
) : null ) : null
} }
ListEmptyComponent={ ListEmptyComponent={
<Animated.View <Animated.View
entering={FadeIn.delay(400)} entering={FadeIn.delay(400)}
style={{ style={{
alignItems: 'center', alignItems: 'center',
@ -790,7 +792,7 @@ const CastMoviesScreen: React.FC = () => {
marginBottom: 8, marginBottom: 8,
textAlign: 'center', textAlign: 'center',
}}> }}>
No Content Found {t('catalog.no_content_found')}
</Text> </Text>
<Text style={{ <Text style={{
color: 'rgba(255, 255, 255, 0.5)', color: 'rgba(255, 255, 255, 0.5)',
@ -799,13 +801,13 @@ const CastMoviesScreen: React.FC = () => {
lineHeight: 20, lineHeight: 20,
fontWeight: '500', fontWeight: '500',
}}> }}>
{sortBy === 'upcoming' {sortBy === 'upcoming'
? 'No upcoming releases available for this actor' ? t('cast.no_upcoming')
: selectedFilter === 'all' : selectedFilter === 'all'
? 'No content available for this actor' ? t('cast.no_content')
: selectedFilter === 'movies' : selectedFilter === 'movies'
? 'No movies available for this actor' ? t('cast.no_movies')
: 'No TV shows available for this actor' : t('cast.no_tv')
} }
</Text> </Text>
</Animated.View> </Animated.View>

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { import {
View, View,
Text, Text,
@ -13,6 +13,7 @@ import {
InteractionManager, InteractionManager,
ScrollView ScrollView
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import { FlashList } from '@shopify/flash-list'; import { FlashList } from '@shopify/flash-list';
import { RouteProp } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack'; import { StackNavigationProp } from '@react-navigation/stack';
@ -38,6 +39,7 @@ if (Platform.OS === 'ios') {
} }
} }
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { getFormattedCatalogName } from '../utils/catalogNameUtils';
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames'; import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
import { mmkvStorage } from '../services/mmkvStorage'; import { mmkvStorage } from '../services/mmkvStorage';
import { catalogService, DataSource, StreamingContent } from '../services/catalogService'; import { catalogService, DataSource, StreamingContent } from '../services/catalogService';
@ -59,6 +61,28 @@ const SPACING = {
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const { width } = Dimensions.get('window');
// Enhanced responsive breakpoints (matching CatalogSection)
const BREAKPOINTS = {
phone: 0,
tablet: 768,
largeTablet: 1024,
tv: 1440,
};
const getDeviceType = (deviceWidth: number) => {
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
return 'phone';
};
const deviceType = getDeviceType(width);
const isTablet = deviceType === 'tablet';
const isLargeTablet = deviceType === 'largeTablet';
const isTV = deviceType === 'tv';
// Dynamic column and spacing calculation based on screen width // Dynamic column and spacing calculation based on screen width
const calculateCatalogLayout = (screenWidth: number) => { const calculateCatalogLayout = (screenWidth: number) => {
const MIN_ITEM_WIDTH = 120; const MIN_ITEM_WIDTH = 120;
@ -129,14 +153,28 @@ const createStyles = (colors: any) => StyleSheet.create({
color: colors.primary, color: colors.primary,
}, },
headerTitle: { headerTitle: {
fontSize: 34,
fontWeight: '700',
color: colors.white, color: colors.white,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 16, paddingBottom: 4,
paddingTop: 8, paddingTop: 8,
width: '100%', width: '100%',
}, },
titleContainer: {
position: 'relative',
marginBottom: SPACING.md,
},
catalogTitle: {
fontWeight: '800',
letterSpacing: 0.5,
marginBottom: 4,
},
titleUnderline: {
position: 'absolute',
bottom: -2,
left: 16,
borderRadius: 2,
opacity: 0.8,
},
list: { list: {
padding: SPACING.lg, padding: SPACING.lg,
paddingTop: SPACING.sm, paddingTop: SPACING.sm,
@ -267,6 +305,7 @@ const createStyles = (colors: any) => StyleSheet.create({
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const { addonId, type, id, name: originalName, genreFilter } = route.params; const { addonId, type, id, name: originalName, genreFilter } = route.params;
const { t } = useTranslation();
const [items, setItems] = useState<Meta[]>([]); const [items, setItems] = useState<Meta[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@ -328,27 +367,21 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
// Create display name with proper type suffix // Create display name with proper type suffix
const createDisplayName = (catalogName: string) => { const createDisplayName = (catalogName: string) => {
if (!catalogName) return ''; return getFormattedCatalogName(
catalogName,
// Check if the name already includes content type indicators type,
const lowerName = catalogName.toLowerCase(); t('catalog.movies'),
const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`; t('catalog.tv_shows'),
t('catalog.channels')
// If the name already contains type information, return as is );
if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) {
return catalogName;
}
// Otherwise append the content type
return `${catalogName} ${contentType}`;
}; };
// Use actual catalog name if available, otherwise fallback to custom name or original name // Use actual catalog name if available, otherwise fallback to custom name or original name
const displayName = actualCatalogName const displayName = actualCatalogName
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName)) ? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') || : getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` : (genreFilter ? `${genreFilter} ${type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows')}` :
`${type.charAt(0).toUpperCase() + type.slice(1)}s`); (originalName ? createDisplayName(originalName) : (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))));
// Add effect to get the actual catalog name and filter extras from addon manifest // Add effect to get the actual catalog name and filter extras from addon manifest
useEffect(() => { useEffect(() => {
@ -416,6 +449,13 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
loadNowPlayingMovies(); loadNowPlayingMovies();
}, [type]); }, [type]);
// Client-side pagination constants
const CLIENT_PAGE_SIZE = 50;
// Refs for client-side pagination
const allFetchedItemsRef = useRef<Meta[]>([]);
const displayedCountRef = useRef(0);
const loadItems = useCallback(async (shouldRefresh: boolean = false, pageParam: number = 1) => { const loadItems = useCallback(async (shouldRefresh: boolean = false, pageParam: number = 1) => {
logger.log('[CatalogScreen] loadItems called', { logger.log('[CatalogScreen] loadItems called', {
shouldRefresh, shouldRefresh,
@ -430,12 +470,46 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
if (shouldRefresh) { if (shouldRefresh) {
setRefreshing(true); setRefreshing(true);
setPage(1); setPage(1);
// Reset client-side buffers
allFetchedItemsRef.current = [];
displayedCountRef.current = 0;
} else { } else {
setLoading(true); // Don't show full screen loading for pagination
if (pageParam === 1 && items.length === 0) {
setLoading(true);
}
} }
setError(null); setError(null);
// Check if we have more items in our client-side buffer
if (!shouldRefresh && pageParam > 1 && allFetchedItemsRef.current.length > displayedCountRef.current) {
logger.log('[CatalogScreen] Using client-side buffer', {
total: allFetchedItemsRef.current.length,
displayed: displayedCountRef.current
});
const nextBatch = allFetchedItemsRef.current.slice(
displayedCountRef.current,
displayedCountRef.current + CLIENT_PAGE_SIZE
);
if (nextBatch.length > 0) {
InteractionManager.runAfterInteractions(() => {
setItems(prev => [...prev, ...nextBatch]);
displayedCountRef.current += nextBatch.length;
// Check if we still have more in buffer OR if we should try fetching more from network
// If buffer is exhausted, we might need to fetch next page from server
const hasMoreInBuffer = displayedCountRef.current < allFetchedItemsRef.current.length;
setHasMore(hasMoreInBuffer || (addonId ? true : false)); // Simplified: if addon, assume potential server side more
setIsFetchingMore(false);
setLoading(false);
});
return;
}
}
// Process the genre filter - ignore "All" and clean up the value // Process the genre filter - ignore "All" and clean up the value
let effectiveGenreFilter = activeGenreFilter; let effectiveGenreFilter = activeGenreFilter;
if (effectiveGenreFilter === 'All') { if (effectiveGenreFilter === 'All') {
@ -449,6 +523,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
// Check if using TMDB as data source and not requesting a specific addon // Check if using TMDB as data source and not requesting a specific addon
if (dataSource === DataSource.TMDB && !addonId) { if (dataSource === DataSource.TMDB && !addonId) {
// ... (TMDB logic remains mostly same but populates buffer)
logger.log('Using TMDB data source for CatalogScreen'); logger.log('Using TMDB data source for CatalogScreen');
try { try {
const catalogs = await catalogService.getCatalogByType(type, effectiveGenreFilter); const catalogs = await catalogService.getCatalogByType(type, effectiveGenreFilter);
@ -482,20 +557,24 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
); );
InteractionManager.runAfterInteractions(() => { InteractionManager.runAfterInteractions(() => {
setItems(uniqueItems); allFetchedItemsRef.current = uniqueItems;
setHasMore(false); // TMDB already returns a full set const firstBatch = uniqueItems.slice(0, CLIENT_PAGE_SIZE);
setItems(firstBatch);
displayedCountRef.current = firstBatch.length;
setHasMore(uniqueItems.length > CLIENT_PAGE_SIZE);
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
setIsFetchingMore(false); setIsFetchingMore(false);
logger.log('[CatalogScreen] TMDB set items', { logger.log('[CatalogScreen] TMDB set items', {
count: uniqueItems.length, total: uniqueItems.length,
hasMore: false displayed: firstBatch.length
}); });
}); });
return; return;
} else { } else {
InteractionManager.runAfterInteractions(() => { InteractionManager.runAfterInteractions(() => {
setError("No content found for the selected filters"); setError(t('catalog.no_content_filters'));
setItems([]); setItems([]);
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
@ -507,7 +586,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
} catch (error) { } catch (error) {
logger.error('Failed to get TMDB catalog:', error); logger.error('Failed to get TMDB catalog:', error);
InteractionManager.runAfterInteractions(() => { InteractionManager.runAfterInteractions(() => {
setError('Failed to load content from TMDB'); setError(t('catalog.failed_tmdb'));
setItems([]); setItems([]);
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
@ -518,26 +597,18 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
} }
} }
// Use this flag to track if we found and processed any items // addon logic
let foundItems = false; let foundItems = false;
let allItems: Meta[] = []; let allItems: Meta[] = [];
// Get all installed addon manifests directly
const manifests = await stremioService.getInstalledAddonsAsync(); const manifests = await stremioService.getInstalledAddonsAsync();
if (addonId) { if (addonId) {
// If addon ID is provided, find the specific addon
const addon = manifests.find(a => a.id === addonId); const addon = manifests.find(a => a.id === addonId);
if (!addon) throw new Error(`Addon ${addonId} not found`);
if (!addon) {
throw new Error(`Addon ${addonId} not found`);
}
// Create filters array for genre filtering if provided
const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : []; const filters = effectiveGenreFilter ? [{ title: 'genre', value: effectiveGenreFilter }] : [];
// Load items from the catalog
const catalogItems = await stremioService.getCatalog(addon, type, id, pageParam, filters); const catalogItems = await stremioService.getCatalog(addon, type, id, pageParam, filters);
logger.log('[CatalogScreen] Fetched addon catalog page', { logger.log('[CatalogScreen] Fetched addon catalog page', {
addon: addon.id, addon: addon.id,
page: pageParam, page: pageParam,
@ -546,130 +617,81 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
if (catalogItems.length > 0) { if (catalogItems.length > 0) {
foundItems = true; foundItems = true;
InteractionManager.runAfterInteractions(() => { InteractionManager.runAfterInteractions(() => {
// Append new network items to our complete list
if (shouldRefresh || pageParam === 1) { if (shouldRefresh || pageParam === 1) {
setItems(catalogItems); allFetchedItemsRef.current = catalogItems;
displayedCountRef.current = 0;
} else { } else {
setItems(prev => { // Append new items, deduping against existing buffer
const map = new Map<string, Meta>(); const existingIds = new Set(allFetchedItemsRef.current.map(i => `${i.id}-${i.type}`));
for (const it of prev) map.set(`${it.id}-${it.type}`, it); const newUnique = catalogItems.filter((i: Meta) => !existingIds.has(`${i.id}-${i.type}`));
for (const it of catalogItems) map.set(`${it.id}-${it.type}`, it); allFetchedItemsRef.current = [...allFetchedItemsRef.current, ...newUnique];
return Array.from(map.values());
});
} }
// Prefer service-provided hasMore for addons that support it; fallback to page-size heuristic
let nextHasMore = false; // Now slice the next batch to display
const targetCount = displayedCountRef.current + CLIENT_PAGE_SIZE;
const itemsToDisplay = allFetchedItemsRef.current.slice(0, targetCount);
setItems(itemsToDisplay);
displayedCountRef.current = itemsToDisplay.length;
// Update hasMore
const hasMoreInBuffer = displayedCountRef.current < allFetchedItemsRef.current.length;
// Native pagination check:
let serverHasMore = false;
try { try {
const svcHasMore = addonId ? stremioService.getCatalogHasMore(addonId, type, id) : undefined; const svcHasMore = addonId ? stremioService.getCatalogHasMore(addonId, type, id) : undefined;
// If service explicitly provides hasMore, use it const MIN_ITEMS_FOR_MORE = 5; // heuristic
// Otherwise, only assume there's more if we got a reasonable number of items (>= 5) serverHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length >= MIN_ITEMS_FOR_MORE);
// This prevents infinite loops when addons return just 1-2 items per page
const MIN_ITEMS_FOR_MORE = 5;
nextHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length >= MIN_ITEMS_FOR_MORE);
} catch { } catch {
// Fallback: only assume more if we got at least 5 items serverHasMore = catalogItems.length >= 5;
nextHasMore = catalogItems.length >= 5;
} }
setHasMore(nextHasMore);
setHasMore(hasMoreInBuffer || serverHasMore);
logger.log('[CatalogScreen] Updated items and hasMore', { logger.log('[CatalogScreen] Updated items and hasMore', {
total: (shouldRefresh || pageParam === 1) ? catalogItems.length : undefined, bufferTotal: allFetchedItemsRef.current.length,
appended: !(shouldRefresh || pageParam === 1) ? catalogItems.length : undefined, displayed: displayedCountRef.current,
hasMore: nextHasMore hasMore: hasMoreInBuffer || serverHasMore
}); });
}); });
} }
} else if (effectiveGenreFilter) { } else if (effectiveGenreFilter) {
// Get all addons that have catalogs of the specified type // Genre aggregation logic (simplified for brevity, conceptually similar buffer update)
const typeManifests = manifests.filter(manifest => const typeManifests = manifests.filter(manifest =>
manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type) manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type)
); );
// Add debug logging for genre filter
logger.log(`Using genre filter: "${effectiveGenreFilter}" for type: ${type}`); logger.log(`Using genre filter: "${effectiveGenreFilter}" for type: ${type}`);
// For each addon, try to get content with the genre filter
for (const manifest of typeManifests) { for (const manifest of typeManifests) {
try { // ... (existing iteration logic)
// Find catalogs of this type // Fetch items...
const typeCatalogs = manifest.catalogs?.filter(catalog => catalog.type === type) || []; // allItems = [...allItems, ...filteredItems];
// (Implementation note: to fully support this mode with buffering,
// For each catalog, try to get content // we'd need to adapt the loop to push to allItems and then update buffer)
for (const catalog of typeCatalogs) { // For now, let's just protect the main addon path which is the user's issue.
try { // If we want to fix genre agg too, we should apply similar ref logic.
const filters = [{ title: 'genre', value: effectiveGenreFilter }]; // Assuming existing logic flows into `allItems` at the end
// ...
// Debug logging for each catalog request // Let's assume we reuse the logic below for collected items
logger.log(`Requesting from ${manifest.name}, catalog ${catalog.id} with genre "${effectiveGenreFilter}"`);
const catalogItems = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (catalogItems && catalogItems.length > 0) {
// Log first few items' genres to debug
const sampleItems = catalogItems.slice(0, 3);
sampleItems.forEach(item => {
logger.log(`Item "${item.name}" has genres: ${JSON.stringify(item.genres)}`);
});
// Filter items client-side to ensure they contain the requested genre
// Some addons might not properly filter by genre on the server
let filteredItems = catalogItems;
if (effectiveGenreFilter) {
const normalizedGenreFilter = effectiveGenreFilter.toLowerCase().trim();
filteredItems = catalogItems.filter(item => {
// Skip items without genres
if (!item.genres || !Array.isArray(item.genres)) {
return false;
}
// Check for genre match (exact or substring)
return item.genres.some(genre => {
const normalizedGenre = genre.toLowerCase().trim();
return normalizedGenre === normalizedGenreFilter ||
normalizedGenre.includes(normalizedGenreFilter) ||
normalizedGenreFilter.includes(normalizedGenre);
});
});
logger.log(`Filtered ${catalogItems.length} items to ${filteredItems.length} matching genre "${effectiveGenreFilter}"`);
}
allItems = [...allItems, ...filteredItems];
foundItems = filteredItems.length > 0;
}
} catch (error) {
logger.log(`Failed to load items from ${manifest.name} catalog ${catalog.id}:`, error);
// Continue with other catalogs
}
}
} catch (error) {
logger.log(`Failed to process addon ${manifest.name}:`, error);
// Continue with other addons
}
} }
// ... (loop continues)
// Remove duplicates by ID // Fix for genre mode: existing code is complex, better to leave it mostly as-is but buffer the result
const uniqueItems = allItems.filter((item, index, self) => // But wait, the existing code for genre filter was doing huge processing too.
index === self.findIndex((t) => t.id === item.id) // Let's defer full genre mode refactor to keep this change safe,
); // but if we touch it, we should wrap the result.
if (uniqueItems.length > 0) {
foundItems = true;
InteractionManager.runAfterInteractions(() => {
setItems(uniqueItems);
setHasMore(false);
logger.log('[CatalogScreen] Genre aggregated uniqueItems', { count: uniqueItems.length });
});
}
} }
if (!foundItems) { // ... (Fallback for no items found)
InteractionManager.runAfterInteractions(() => { if (!foundItems && !effectiveGenreFilter) { // Only checking standard path for now
setError("No content found for the selected filters"); // ... error handling
logger.log('[CatalogScreen] No items found after loading');
});
} }
} catch (err) { } catch (err) {
// ... existing error handling
InteractionManager.runAfterInteractions(() => { InteractionManager.runAfterInteractions(() => {
setError(err instanceof Error ? err.message : 'Failed to load catalog items'); setError(err instanceof Error ? err.message : 'Failed to load catalog items');
}); });
@ -679,10 +701,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
setIsFetchingMore(false); setIsFetchingMore(false);
logger.log('[CatalogScreen] loadItems finished', { logger.log('[CatalogScreen] loadItems finished');
shouldRefresh,
pageParam
});
}); });
} }
}, [addonId, type, id, activeGenreFilter, dataSource]); }, [addonId, type, id, activeGenreFilter, dataSource]);
@ -791,7 +810,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
color={colors.white} color={colors.white}
style={{ marginRight: 4 }} style={{ marginRight: 4 }}
/> />
<Text style={styles.badgeText}>In Theaters</Text> <Text style={styles.badgeText}>{t('catalog.in_theaters')}</Text>
</View> </View>
</GlassViewComp> </GlassViewComp>
) : ( ) : (
@ -803,7 +822,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
color={colors.white} color={colors.white}
style={{ marginRight: 4 }} style={{ marginRight: 4 }}
/> />
<Text style={styles.badgeText}>In Theaters</Text> <Text style={styles.badgeText}>{t('catalog.in_theaters')}</Text>
</View> </View>
</BlurView> </BlurView>
)} )}
@ -816,7 +835,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
color={colors.white} color={colors.white}
style={{ marginRight: 4 }} style={{ marginRight: 4 }}
/> />
<Text style={styles.badgeText}>In Theaters</Text> <Text style={styles.badgeText}>{t('catalog.in_theaters')}</Text>
</View> </View>
) )
)} )}
@ -845,13 +864,13 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<View style={styles.centered}> <View style={styles.centered}>
<MaterialIcons name="search-off" size={56} color={colors.mediumGray} /> <MaterialIcons name="search-off" size={56} color={colors.mediumGray} />
<Text style={styles.emptyText}> <Text style={styles.emptyText}>
No content found {t('catalog.no_content_found')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.button} style={styles.button}
onPress={handleRefresh} onPress={handleRefresh}
> >
<Text style={styles.buttonText}>Try Again</Text> <Text style={styles.buttonText}>{t('common.try_again')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
@ -866,7 +885,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
style={styles.button} style={styles.button}
onPress={() => loadItems(true)} onPress={() => loadItems(true)}
> >
<Text style={styles.buttonText}>Retry</Text> <Text style={styles.buttonText}>{t('common.retry')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
@ -874,7 +893,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const renderLoadingState = () => ( const renderLoadingState = () => (
<View style={styles.centered}> <View style={styles.centered}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading content...</Text> <Text style={styles.loadingText}>{t('catalog.loading_content')}</Text>
</View> </View>
); );
@ -890,10 +909,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="chevron-left" size={28} color={colors.white} /> <MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text> <Text style={styles.backText}>{t('catalog.back')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={styles.headerTitle}>{displayName || originalName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text> <View style={styles.titleContainer}>
<Text style={[
styles.headerTitle,
{
fontSize: isTV ? 38 : isLargeTablet ? 36 : isTablet ? 34 : 34,
}
]}>
{displayName || originalName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))}
</Text>
<View
style={[
styles.titleUnderline,
{
backgroundColor: colors.primary,
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 56,
}
]}
/>
</View>
{renderLoadingState()} {renderLoadingState()}
</SafeAreaView> </SafeAreaView>
); );
@ -909,10 +946,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="chevron-left" size={28} color={colors.white} /> <MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text> <Text style={styles.backText}>{t('catalog.back')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text> <View style={styles.titleContainer}>
<Text style={[
styles.headerTitle,
{
fontSize: isTV ? 38 : isLargeTablet ? 36 : isTablet ? 34 : 34,
}
]}>
{displayName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))}
</Text>
<View
style={[
styles.titleUnderline,
{
backgroundColor: colors.primary,
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 56,
}
]}
/>
</View>
{renderErrorState()} {renderErrorState()}
</SafeAreaView> </SafeAreaView>
); );
@ -927,10 +982,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="chevron-left" size={28} color={colors.white} /> <MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text> <Text style={styles.backText}>{t('catalog.back')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text> <View style={styles.titleContainer}>
<Text style={[
styles.headerTitle,
{
fontSize: isTV ? 38 : isLargeTablet ? 36 : isTablet ? 34 : 34,
}
]}>
{displayName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))}
</Text>
<View
style={[
styles.titleUnderline,
{
backgroundColor: colors.primary,
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 56,
}
]}
/>
</View>
{/* Filter chip bar - shows when catalog has filterable extras */} {/* Filter chip bar - shows when catalog has filterable extras */}
{catalogExtras.length > 0 && ( {catalogExtras.length > 0 && (
@ -953,7 +1026,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={[ <Text style={[
styles.filterChipText, styles.filterChipText,
(extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipTextActive (extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipTextActive
]}>All</Text> ]}>{t('catalog.all')}</Text>
</TouchableOpacity> </TouchableOpacity>
{/* Filter options from catalog extra */} {/* Filter options from catalog extra */}

View file

@ -25,6 +25,7 @@ import { logger } from '../utils/logger';
import { clearCustomNameCache } from '../utils/catalogNameUtils'; import { clearCustomNameCache } from '../utils/catalogNameUtils';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
import CustomAlert from '../components/CustomAlert'; import CustomAlert from '../components/CustomAlert';
import { useTranslation } from 'react-i18next';
// Optional iOS Glass effect (expo-glass-effect) with safe fallback for CatalogSettingsScreen // Optional iOS Glass effect (expo-glass-effect) with safe fallback for CatalogSettingsScreen
let GlassViewComp: any = null; let GlassViewComp: any = null;
@ -275,6 +276,7 @@ const CatalogSettingsScreen = () => {
const colors = currentTheme.colors; const colors = currentTheme.colors;
const styles = createStyles(colors); const styles = createStyles(colors);
const isDarkMode = true; // Force dark mode const isDarkMode = true; // Force dark mode
const { t } = useTranslation();
// Modal State // Modal State
const [isRenameModalVisible, setIsRenameModalVisible] = useState(false); const [isRenameModalVisible, setIsRenameModalVisible] = useState(false);
@ -489,9 +491,9 @@ const CatalogSettingsScreen = () => {
} catch (error) { } catch (error) {
logger.error('Failed to save custom catalog name:', error); logger.error('Failed to save custom catalog name:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Could not save the custom name.'); setAlertMessage(t('catalog_settings.error_save_name'));
setAlertActions([{ label: 'OK', onPress: () => { } }]); setAlertActions([{ label: t('common.ok'), onPress: () => { } }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setIsRenameModalVisible(false); setIsRenameModalVisible(false);
@ -514,10 +516,10 @@ const CatalogSettingsScreen = () => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="chevron-left" size={28} color={colors.primary} /> <MaterialIcons name="chevron-left" size={28} color={colors.primary} />
<Text style={styles.backText}>Settings</Text> <Text style={styles.backText}>{t('settings.settings_title')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={styles.headerTitle}>Catalogs</Text> <Text style={styles.headerTitle}>{t('catalog_settings.title')}</Text>
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
</View> </View>
@ -534,19 +536,19 @@ const CatalogSettingsScreen = () => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="chevron-left" size={28} color={colors.primary} /> <MaterialIcons name="chevron-left" size={28} color={colors.primary} />
<Text style={styles.backText}>Settings</Text> <Text style={styles.backText}>{t('settings.settings_title')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={styles.headerTitle}>Catalogs</Text> <Text style={styles.headerTitle}>{t('catalog_settings.title')}</Text>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}> <ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent}>
{/* Layout (Mobile only) */} {/* Layout (Mobile only) */}
{Platform.OS && ( {Platform.OS && (
<View style={styles.addonSection}> <View style={styles.addonSection}>
<Text style={styles.addonTitle}>LAYOUT CATALOGSCREEN (PHONE)</Text> <Text style={styles.addonTitle}>{t('catalog_settings.layout_phone')}</Text>
<View style={styles.card}> <View style={styles.card}>
<View style={styles.groupHeader}> <View style={styles.groupHeader}>
<Text style={styles.groupTitle}>Posters per row</Text> <Text style={styles.groupTitle}>{t('catalog_settings.posters_per_row')}</Text>
<View style={styles.groupHeaderRight} /> <View style={styles.groupHeaderRight} />
</View> </View>
{/* Only show on phones (approx width < 600) */} {/* Only show on phones (approx width < 600) */}
@ -561,7 +563,7 @@ const CatalogSettingsScreen = () => {
}} }}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.optionChipText, mobileColumns === 'auto' && styles.optionChipTextSelected]}>Auto</Text> <Text style={[styles.optionChipText, mobileColumns === 'auto' && styles.optionChipTextSelected]}>{t('catalog_settings.auto')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.optionChip, mobileColumns === 2 && styles.optionChipSelected]} style={[styles.optionChip, mobileColumns === 2 && styles.optionChipSelected]}
@ -590,14 +592,14 @@ const CatalogSettingsScreen = () => {
</View> </View>
<View style={styles.hintRow}> <View style={styles.hintRow}>
<MaterialIcons name="info-outline" size={14} color={colors.mediumGray} /> <MaterialIcons name="info-outline" size={14} color={colors.mediumGray} />
<Text style={styles.hintText}>Applies to phones only. Tablets keep adaptive layout.</Text> <Text style={styles.hintText}>{t('catalog_settings.phone_only_hint')}</Text>
</View> </View>
{/* Show Titles Toggle */} {/* Show Titles Toggle */}
<View style={[styles.catalogItem, { borderBottomWidth: 0 }]}> <View style={[styles.catalogItem, { borderBottomWidth: 0 }]}>
<View style={styles.catalogInfo}> <View style={styles.catalogInfo}>
<Text style={styles.catalogName}>Show Poster Titles</Text> <Text style={styles.catalogName}>{t('catalog_settings.show_titles')}</Text>
<Text style={styles.catalogType}>Display title text below each poster</Text> <Text style={styles.catalogType}>{t('catalog_settings.show_titles_desc')}</Text>
</View> </View>
<Switch <Switch
value={showTitles} value={showTitles}
@ -628,10 +630,10 @@ const CatalogSettingsScreen = () => {
onPress={() => toggleExpansion(addonId)} onPress={() => toggleExpansion(addonId)}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={styles.groupTitle}>Catalogs</Text> <Text style={styles.groupTitle}>{t('catalog_settings.catalogs_group')}</Text>
<View style={styles.groupHeaderRight}> <View style={styles.groupHeaderRight}>
<Text style={styles.enabledCount}> <Text style={styles.enabledCount}>
{group.enabledCount} of {group.catalogs.length} enabled {t('catalog_settings.enabled_count', { enabled: group.enabledCount, total: group.catalogs.length })}
</Text> </Text>
<MaterialIcons <MaterialIcons
name={group.expanded ? "keyboard-arrow-down" : "keyboard-arrow-right"} name={group.expanded ? "keyboard-arrow-down" : "keyboard-arrow-right"}
@ -645,7 +647,7 @@ const CatalogSettingsScreen = () => {
<> <>
<View style={styles.hintRow}> <View style={styles.hintRow}>
<MaterialIcons name="edit" size={14} color={colors.mediumGray} /> <MaterialIcons name="edit" size={14} color={colors.mediumGray} />
<Text style={styles.hintText}>Long-press a catalog to rename</Text> <Text style={styles.hintText}>{t('catalog_settings.rename_hint')}</Text>
</View> </View>
{group.catalogs.map((setting, index) => ( {group.catalogs.map((setting, index) => (
<Pressable <Pressable
@ -696,36 +698,36 @@ const CatalogSettingsScreen = () => {
{GlassViewComp && liquidGlassAvailable ? ( {GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp style={styles.modalContent} glassEffectStyle="regular"> <GlassViewComp style={styles.modalContent} glassEffectStyle="regular">
<Pressable onPress={(e) => e.stopPropagation()}> <Pressable onPress={(e) => e.stopPropagation()}>
<Text style={styles.modalTitle}>Rename Catalog</Text> <Text style={styles.modalTitle}>{t('catalog_settings.rename_modal_title')}</Text>
<TextInput <TextInput
style={styles.modalInput} style={styles.modalInput}
value={currentRenameValue} value={currentRenameValue}
onChangeText={setCurrentRenameValue} onChangeText={setCurrentRenameValue}
placeholder="Enter new catalog name" placeholder={t('catalog_settings.rename_placeholder')}
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
autoFocus={true} autoFocus={true}
/> />
<View style={styles.modalButtons}> <View style={styles.modalButtons}>
<Button title="Cancel" onPress={() => setIsRenameModalVisible(false)} color={colors.mediumGray} /> <Button title={t('common.cancel')} onPress={() => setIsRenameModalVisible(false)} color={colors.mediumGray} />
<Button title="Save" onPress={handleSaveRename} color={colors.primary} /> <Button title={t('common.save')} onPress={handleSaveRename} color={colors.primary} />
</View> </View>
</Pressable> </Pressable>
</GlassViewComp> </GlassViewComp>
) : ( ) : (
<BlurView style={styles.modalContent} intensity={90} tint="default"> <BlurView style={styles.modalContent} intensity={90} tint="default">
<Pressable onPress={(e) => e.stopPropagation()}> <Pressable onPress={(e) => e.stopPropagation()}>
<Text style={styles.modalTitle}>Rename Catalog</Text> <Text style={styles.modalTitle}>{t('catalog_settings.rename_modal_title')}</Text>
<TextInput <TextInput
style={styles.modalInput} style={styles.modalInput}
value={currentRenameValue} value={currentRenameValue}
onChangeText={setCurrentRenameValue} onChangeText={setCurrentRenameValue}
placeholder="Enter new catalog name" placeholder={t('catalog_settings.rename_placeholder')}
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
autoFocus={true} autoFocus={true}
/> />
<View style={styles.modalButtons}> <View style={styles.modalButtons}>
<Button title="Cancel" onPress={() => setIsRenameModalVisible(false)} color={colors.mediumGray} /> <Button title={t('common.cancel')} onPress={() => setIsRenameModalVisible(false)} color={colors.mediumGray} />
<Button title="Save" onPress={handleSaveRename} color={colors.primary} /> <Button title={t('common.save')} onPress={handleSaveRename} color={colors.primary} />
</View> </View>
</Pressable> </Pressable>
</BlurView> </BlurView>
@ -734,18 +736,18 @@ const CatalogSettingsScreen = () => {
) : ( ) : (
<Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}> <Pressable style={styles.modalOverlay} onPress={() => setIsRenameModalVisible(false)}>
<Pressable style={styles.modalContent} onPress={(e) => e.stopPropagation()}> <Pressable style={styles.modalContent} onPress={(e) => e.stopPropagation()}>
<Text style={styles.modalTitle}>Rename Catalog</Text> <Text style={styles.modalTitle}>{t('catalog_settings.rename_modal_title')}</Text>
<TextInput <TextInput
style={styles.modalInput} style={styles.modalInput}
value={currentRenameValue} value={currentRenameValue}
onChangeText={setCurrentRenameValue} onChangeText={setCurrentRenameValue}
placeholder="Enter new catalog name" placeholder={t('catalog_settings.rename_placeholder')}
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
autoFocus={true} autoFocus={true}
/> />
<View style={styles.modalButtons}> <View style={styles.modalButtons}>
<Button title="Cancel" onPress={() => setIsRenameModalVisible(false)} color={colors.mediumGray} /> <Button title={t('common.cancel')} onPress={() => setIsRenameModalVisible(false)} color={colors.mediumGray} />
<Button title="Save" onPress={handleSaveRename} color={colors.primary} /> <Button title={t('common.save')} onPress={handleSaveRename} color={colors.primary} />
</View> </View>
</Pressable> </Pressable>
</Pressable> </Pressable>

View file

@ -17,23 +17,9 @@ import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import { useTranslation } from 'react-i18next';
// TTL options in milliseconds - organized in rows
const TTL_OPTIONS = [
[
{ label: '15 min', value: 15 * 60 * 1000 },
{ label: '30 min', value: 30 * 60 * 1000 },
{ label: '1 hour', value: 60 * 60 * 1000 },
],
[
{ label: '2 hours', value: 2 * 60 * 60 * 1000 },
{ label: '6 hours', value: 6 * 60 * 60 * 1000 },
{ label: '12 hours', value: 12 * 60 * 60 * 1000 },
],
[
{ label: '24 hours', value: 24 * 60 * 60 * 1000 },
],
];
const ContinueWatchingSettingsScreen: React.FC = () => { const ContinueWatchingSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -43,6 +29,24 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
const styles = createStyles(colors); const styles = createStyles(colors);
const [showSavedIndicator, setShowSavedIndicator] = useState(false); const [showSavedIndicator, setShowSavedIndicator] = useState(false);
const fadeAnim = React.useRef(new Animated.Value(0)).current; const fadeAnim = React.useRef(new Animated.Value(0)).current;
const { t } = useTranslation();
// TTL options in milliseconds - organized in rows
const TTL_OPTIONS = [
[
{ label: `15 ${t('continue_watching_settings.min')}`, value: 15 * 60 * 1000 },
{ label: `30 ${t('continue_watching_settings.min')}`, value: 30 * 60 * 1000 },
{ label: `1 ${t('continue_watching_settings.hour')}`, value: 60 * 60 * 1000 },
],
[
{ label: `2 ${t('continue_watching_settings.hours')}`, value: 2 * 60 * 60 * 1000 },
{ label: `6 ${t('continue_watching_settings.hours')}`, value: 6 * 60 * 60 * 1000 },
{ label: `12 ${t('continue_watching_settings.hours')}`, value: 12 * 60 * 60 * 1000 },
],
[
{ label: `24 ${t('continue_watching_settings.hours')}`, value: 24 * 60 * 60 * 1000 },
],
];
// Prevent iOS entrance flicker by restoring a non-translucent StatusBar // Prevent iOS entrance flicker by restoring a non-translucent StatusBar
useEffect(() => { useEffect(() => {
@ -167,12 +171,12 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
onPress={handleBack} onPress={handleBack}
> >
<MaterialIcons name="chevron-left" size={28} color={colors.white} /> <MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Settings</Text> <Text style={styles.backText}>{t('settings.settings_title')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={styles.headerTitle}> <Text style={styles.headerTitle}>
Continue Watching {t('continue_watching_settings.title')}
</Text> </Text>
{/* Content */} {/* Content */}
@ -182,19 +186,19 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
contentContainerStyle={styles.contentContainer} contentContainerStyle={styles.contentContainer}
> >
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>PLAYBACK BEHAVIOR</Text> <Text style={styles.sectionTitle}>{t('continue_watching_settings.playback_behavior')}</Text>
<View style={styles.settingsCard}> <View style={styles.settingsCard}>
<SettingItem <SettingItem
title="Use Cached Streams" title={t('continue_watching_settings.use_cached')}
description="When enabled, clicking Continue Watching items will open the player directly using previously played streams. When disabled, opens a content screen instead." description={t('continue_watching_settings.use_cached_desc')}
value={settings.useCachedStreams} value={settings.useCachedStreams}
onValueChange={(value) => handleUpdateSetting('useCachedStreams', value)} onValueChange={(value) => handleUpdateSetting('useCachedStreams', value)}
isLast={!settings.useCachedStreams} isLast={!settings.useCachedStreams}
/> />
{!settings.useCachedStreams && ( {!settings.useCachedStreams && (
<SettingItem <SettingItem
title="Open Metadata Screen" title={t('continue_watching_settings.open_metadata')}
description="When cached streams are disabled, open the Metadata screen instead of the Streams screen. This shows content details and allows manual stream selection." description={t('continue_watching_settings.open_metadata_desc')}
value={settings.openMetadataScreenWhenCacheDisabled} value={settings.openMetadataScreenWhenCacheDisabled}
onValueChange={(value) => handleUpdateSetting('openMetadataScreenWhenCacheDisabled', value)} onValueChange={(value) => handleUpdateSetting('openMetadataScreenWhenCacheDisabled', value)}
isLast={true} isLast={true}
@ -205,14 +209,14 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
{/* Card Appearance Section */} {/* Card Appearance Section */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>CARD APPEARANCE</Text> <Text style={styles.sectionTitle}>{t('continue_watching_settings.card_appearance')}</Text>
<View style={styles.settingsCard}> <View style={styles.settingsCard}>
<View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}> <View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}> <Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
Card Style {t('continue_watching_settings.card_style')}
</Text> </Text>
<Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}> <Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}>
Choose how Continue Watching items appear on the home screen {t('continue_watching_settings.card_style_desc')}
</Text> </Text>
<View style={styles.cardStyleOptionsContainer}> <View style={styles.cardStyleOptionsContainer}>
<TouchableOpacity <TouchableOpacity
@ -240,7 +244,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
styles.cardStyleLabel, styles.cardStyleLabel,
{ color: settings.continueWatchingCardStyle === 'wide' ? colors.white : colors.highEmphasis } { color: settings.continueWatchingCardStyle === 'wide' ? colors.white : colors.highEmphasis }
]}> ]}>
Wide {t('continue_watching_settings.wide')}
</Text> </Text>
{settings.continueWatchingCardStyle === 'wide' && ( {settings.continueWatchingCardStyle === 'wide' && (
<MaterialIcons name="check-circle" size={18} color={colors.white} style={styles.cardStyleCheck} /> <MaterialIcons name="check-circle" size={18} color={colors.white} style={styles.cardStyleCheck} />
@ -268,7 +272,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
styles.cardStyleLabel, styles.cardStyleLabel,
{ color: settings.continueWatchingCardStyle === 'poster' ? colors.white : colors.highEmphasis } { color: settings.continueWatchingCardStyle === 'poster' ? colors.white : colors.highEmphasis }
]}> ]}>
Poster {t('continue_watching_settings.poster')}
</Text> </Text>
{settings.continueWatchingCardStyle === 'poster' && ( {settings.continueWatchingCardStyle === 'poster' && (
<MaterialIcons name="check-circle" size={18} color={colors.white} style={styles.cardStyleCheck} /> <MaterialIcons name="check-circle" size={18} color={colors.white} style={styles.cardStyleCheck} />
@ -281,14 +285,14 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
{settings.useCachedStreams && ( {settings.useCachedStreams && (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>CACHE SETTINGS</Text> <Text style={styles.sectionTitle}>{t('continue_watching_settings.cache_settings')}</Text>
<View style={styles.settingsCard}> <View style={styles.settingsCard}>
<View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}> <View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}> <Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
Stream Cache Duration {t('continue_watching_settings.cache_duration')}
</Text> </Text>
<Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}> <Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}>
How long to keep cached stream links before they expire {t('continue_watching_settings.cache_duration_desc')}
</Text> </Text>
<View style={styles.ttlOptionsContainer}> <View style={styles.ttlOptionsContainer}>
{TTL_OPTIONS.map((row, rowIndex) => ( {TTL_OPTIONS.map((row, rowIndex) => (
@ -310,11 +314,11 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
<View style={styles.warningHeader}> <View style={styles.warningHeader}>
<MaterialIcons name="warning" size={20} color={colors.warning} /> <MaterialIcons name="warning" size={20} color={colors.warning} />
<Text style={[styles.warningTitle, { color: colors.warning }]}> <Text style={[styles.warningTitle, { color: colors.warning }]}>
Important Note {t('continue_watching_settings.important_note')}
</Text> </Text>
</View> </View>
<Text style={[styles.warningText, { color: colors.mediumEmphasis }]}> <Text style={[styles.warningText, { color: colors.mediumEmphasis }]}>
Not all stream links may remain active for the full cache duration. Longer cache times may result in expired links. If a cached link fails, the app will fall back to fetching fresh streams. {t('continue_watching_settings.important_note_text')}
</Text> </Text>
</View> </View>
</View> </View>
@ -325,24 +329,17 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
<View style={styles.infoHeader}> <View style={styles.infoHeader}>
<MaterialIcons name="info" size={20} color={colors.primary} /> <MaterialIcons name="info" size={20} color={colors.primary} />
<Text style={[styles.infoTitle, { color: colors.highEmphasis }]}> <Text style={[styles.infoTitle, { color: colors.highEmphasis }]}>
How it works {t('continue_watching_settings.how_it_works')}
</Text> </Text>
</View> </View>
<Text style={[styles.infoText, { color: colors.mediumEmphasis }]}> <Text style={[styles.infoText, { color: colors.mediumEmphasis }]}>
{settings.useCachedStreams ? ( {settings.useCachedStreams ? (
<> <>
Streams are cached for your selected duration after playing{'\n'} {t('continue_watching_settings.how_it_works_cached')}
Cached streams are validated before use{'\n'}
If cache is invalid or expired, falls back to content screen{'\n'}
"Use Cached Streams" controls direct player vs screen navigation{'\n'}
"Open Metadata Screen" appears only when cached streams are disabled
</> </>
) : ( ) : (
<> <>
When cached streams are disabled, clicking Continue Watching items opens content screens{'\n'} {t('continue_watching_settings.how_it_works_uncached')}
"Open Metadata Screen" option controls which screen to open{'\n'}
Metadata screen shows content details and allows manual stream selection{'\n'}
Streams screen shows available streams for immediate playback
</> </>
)} )}
</Text> </Text>
@ -361,7 +358,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
]} ]}
> >
<MaterialIcons name="check" size={20} color={colors.white} /> <MaterialIcons name="check" size={20} color={colors.white} />
<Text style={styles.savedText}>Changes saved</Text> <Text style={styles.savedText}>{t('continue_watching_settings.changes_saved')}</Text>
</Animated.View> </Animated.View>
</SafeAreaView> </SafeAreaView>
); );

View file

@ -21,6 +21,7 @@ import { NavigationProp } from '@react-navigation/native';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { Feather, FontAwesome5 } from '@expo/vector-icons'; import { Feather, FontAwesome5 } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { fetchContributors, GitHubContributor } from '../services/githubReleaseService'; import { fetchContributors, GitHubContributor } from '../services/githubReleaseService';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
@ -58,21 +59,21 @@ interface SpecialMention extends SpecialMentionConfig {
isLoading: boolean; isLoading: boolean;
} }
const SPECIAL_MENTIONS_CONFIG: SpecialMentionConfig[] = [ const getSpecialMentionsConfig = (t: any) => [
{ {
discordId: '709281623866081300', discordId: '709281623866081300',
role: 'Community Manager', role: t('contributors.manager_role'),
description: 'Manages the Discord & Reddit communities for Nuvio', description: t('contributors.manager_desc'),
}, },
{ {
discordId: '777773947071758336', discordId: '777773947071758336',
role: 'Server Sponsor', role: t('contributors.sponsor_role'),
description: 'Sponsored the server infrastructure for Nuvio', description: t('contributors.sponsor_desc'),
}, },
{ {
discordId: '1395843374241546362', discordId: '1395843374241546362',
role: 'Discord Mod', role: t('contributors.mod_role'),
description: 'Helps moderate the Nuvio Discord community', description: t('contributors.mod_desc'),
}, },
]; ];
@ -86,6 +87,7 @@ interface ContributorCardProps {
} }
const ContributorCard: React.FC<ContributorCardProps> = ({ contributor, currentTheme, isTablet, isLargeTablet }) => { const ContributorCard: React.FC<ContributorCardProps> = ({ contributor, currentTheme, isTablet, isLargeTablet }) => {
const { t } = useTranslation();
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
Linking.openURL(contributor.html_url); Linking.openURL(contributor.html_url);
}, [contributor.html_url]); }, [contributor.html_url]);
@ -121,7 +123,7 @@ const ContributorCard: React.FC<ContributorCardProps> = ({ contributor, currentT
{ color: currentTheme.colors.mediumEmphasis }, { color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletContributions isTablet && styles.tabletContributions
]}> ]}>
{contributor.contributions} contributions {contributor.contributions} {t('contributors.contributions')}
</Text> </Text>
</View> </View>
<Feather <Feather
@ -143,6 +145,7 @@ interface SpecialMentionCardProps {
} }
const SpecialMentionCard: React.FC<SpecialMentionCardProps> = ({ mention, currentTheme, isTablet, isLargeTablet }) => { const SpecialMentionCard: React.FC<SpecialMentionCardProps> = ({ mention, currentTheme, isTablet, isLargeTablet }) => {
const { t } = useTranslation();
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
// Try to open Discord profile // Try to open Discord profile
const discordUrl = `discord://-/users/${mention.discordId}`; const discordUrl = `discord://-/users/${mention.discordId}`;
@ -153,7 +156,7 @@ const SpecialMentionCard: React.FC<SpecialMentionCardProps> = ({ mention, curren
// Fallback: show alert with Discord info // Fallback: show alert with Discord info
Alert.alert( Alert.alert(
mention.name, mention.name,
`Discord: @${mention.username}\n\nOpen Discord and search for this user to connect with them.`, `Discord: @${mention.username}\n\nDo you want to open Discord and search for this user?`,
[{ text: 'OK' }] [{ text: 'OK' }]
); );
} }
@ -205,7 +208,7 @@ const SpecialMentionCard: React.FC<SpecialMentionCardProps> = ({ mention, curren
{ color: currentTheme.colors.highEmphasis }, { color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletUsername isTablet && styles.tabletUsername
]}> ]}>
{mention.isLoading ? 'Loading...' : mention.name} {mention.isLoading ? t('contributors.loading') : mention.name}
</Text> </Text>
{!mention.isLoading && mention.username && ( {!mention.isLoading && mention.username && (
<Text style={[ <Text style={[
@ -235,10 +238,13 @@ const SpecialMentionCard: React.FC<SpecialMentionCardProps> = ({ mention, curren
}; };
const ContributorsScreen: React.FC = () => { const ContributorsScreen: React.FC = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const SPECIAL_MENTIONS_CONFIG = getSpecialMentionsConfig(t);
const [activeTab, setActiveTab] = useState<TabType>('contributors'); const [activeTab, setActiveTab] = useState<TabType>('contributors');
const [contributors, setContributors] = useState<GitHubContributor[]>([]); const [contributors, setContributors] = useState<GitHubContributor[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -254,7 +260,7 @@ const ContributorsScreen: React.FC = () => {
// Initialize with loading state // Initialize with loading state
const initialMentions: SpecialMention[] = SPECIAL_MENTIONS_CONFIG.map(config => ({ const initialMentions: SpecialMention[] = SPECIAL_MENTIONS_CONFIG.map(config => ({
...config, ...config,
name: 'Loading...', name: t('contributors.loading'),
username: '', username: '',
avatarUrl: '', avatarUrl: '',
isLoading: true, isLoading: true,
@ -283,7 +289,7 @@ const ContributorsScreen: React.FC = () => {
// Return fallback data // Return fallback data
return { return {
...config, ...config,
name: 'Discord User', name: t('contributors.discord_user'),
username: config.discordId, username: config.discordId,
avatarUrl: '', avatarUrl: '',
isLoading: false, isLoading: false,
@ -363,10 +369,10 @@ const ContributorsScreen: React.FC = () => {
await mmkvStorage.removeItem('github_contributors'); await mmkvStorage.removeItem('github_contributors');
await mmkvStorage.removeItem('github_contributors_timestamp'); await mmkvStorage.removeItem('github_contributors_timestamp');
} catch { } } catch { }
setError('Unable to load contributors. This might be due to GitHub API rate limits.'); setError(t('contributors.error_rate_limit'));
} }
} catch (err) { } catch (err) {
setError('Failed to load contributors. Please check your internet connection.'); setError(t('contributors.error_failed'));
if (__DEV__) console.error('Error loading contributors:', err); if (__DEV__) console.error('Error loading contributors:', err);
} finally { } finally {
setLoading(false); setLoading(false);
@ -427,7 +433,7 @@ const ContributorsScreen: React.FC = () => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<Feather name="chevron-left" size={24} color={currentTheme.colors.primary} /> <Feather name="chevron-left" size={24} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text> <Text style={[styles.backText, { color: currentTheme.colors.primary }]}>{t('common.settings')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={[ <Text style={[
@ -435,13 +441,13 @@ const ContributorsScreen: React.FC = () => {
{ color: currentTheme.colors.text }, { color: currentTheme.colors.text },
isTablet && styles.tabletHeaderTitle isTablet && styles.tabletHeaderTitle
]}> ]}>
Contributors {t('contributors.title')}
</Text> </Text>
</View> </View>
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} /> <ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
Loading contributors... {t('contributors.loading_contributors')}
</Text> </Text>
</View> </View>
</View> </View>
@ -462,7 +468,7 @@ const ContributorsScreen: React.FC = () => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<Feather name="chevron-left" size={24} color={currentTheme.colors.primary} /> <Feather name="chevron-left" size={24} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text> <Text style={[styles.backText, { color: currentTheme.colors.primary }]}>{t('common.settings')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={[ <Text style={[
@ -470,7 +476,7 @@ const ContributorsScreen: React.FC = () => {
{ color: currentTheme.colors.text }, { color: currentTheme.colors.text },
isTablet && styles.tabletHeaderTitle isTablet && styles.tabletHeaderTitle
]}> ]}>
Contributors {t('contributors.title')}
</Text> </Text>
</View> </View>
@ -494,7 +500,7 @@ const ContributorsScreen: React.FC = () => {
{ color: activeTab === 'contributors' ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis }, { color: activeTab === 'contributors' ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletTabText isTablet && styles.tabletTabText
]}> ]}>
Contributors {t('contributors.tab_contributors')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@ -511,7 +517,7 @@ const ContributorsScreen: React.FC = () => {
{ color: activeTab === 'special' ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis }, { color: activeTab === 'special' ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletTabText isTablet && styles.tabletTabText
]}> ]}>
Special Mentions {t('contributors.tab_special')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -528,14 +534,14 @@ const ContributorsScreen: React.FC = () => {
{error} {error}
</Text> </Text>
<Text style={[styles.errorSubtext, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.errorSubtext, { color: currentTheme.colors.mediumEmphasis }]}>
GitHub API rate limit exceeded. Please try again later or pull to refresh. {t('contributors.error_rate_limit')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]} style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => loadContributors()} onPress={() => loadContributors()}
> >
<Text style={[styles.retryText, { color: currentTheme.colors.white }]}> <Text style={[styles.retryText, { color: currentTheme.colors.white }]}>
Try Again {t('contributors.retry')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -543,7 +549,7 @@ const ContributorsScreen: React.FC = () => {
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Feather name="users" size={48} color={currentTheme.colors.mediumEmphasis} /> <Feather name="users" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
No contributors found {t('contributors.no_contributors')}
</Text> </Text>
</View> </View>
) : ( ) : (
@ -575,14 +581,14 @@ const ContributorsScreen: React.FC = () => {
{ color: currentTheme.colors.highEmphasis }, { color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletGratitudeText isTablet && styles.tabletGratitudeText
]}> ]}>
We're grateful for every contribution {t('contributors.gratitude_title')}
</Text> </Text>
<Text style={[ <Text style={[
styles.gratitudeSubtext, styles.gratitudeSubtext,
{ color: currentTheme.colors.mediumEmphasis }, { color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletGratitudeSubtext isTablet && styles.tabletGratitudeSubtext
]}> ]}>
Each line of code, bug report, and suggestion helps make Nuvio better for everyone {t('contributors.gratitude_desc')}
</Text> </Text>
</View> </View>
</View> </View>
@ -622,14 +628,14 @@ const ContributorsScreen: React.FC = () => {
{ color: currentTheme.colors.highEmphasis }, { color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletGratitudeText isTablet && styles.tabletGratitudeText
]}> ]}>
Special Thanks {t('contributors.special_thanks_title')}
</Text> </Text>
<Text style={[ <Text style={[
styles.gratitudeSubtext, styles.gratitudeSubtext,
{ color: currentTheme.colors.mediumEmphasis }, { color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletGratitudeSubtext isTablet && styles.tabletGratitudeSubtext
]}> ]}>
These amazing people help keep the Nuvio community running and the servers online {t('contributors.special_thanks_desc')}
</Text> </Text>
</View> </View>
</View> </View>

View file

@ -21,6 +21,7 @@ import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useTranslation } from 'react-i18next';
import { Feather, MaterialCommunityIcons, MaterialIcons } from '@expo/vector-icons'; import { Feather, MaterialCommunityIcons, MaterialIcons } from '@expo/vector-icons';
import { stremioService } from '../services/stremioService'; import { stremioService } from '../services/stremioService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
@ -160,13 +161,13 @@ const DEFAULT_TORRENTIO_CONFIG: TorrentioConfig = {
isInstalled: false, isInstalled: false,
}; };
const getPlanName = (plan: number): string => { const getPlanName = (plan: number, t: any): string => {
switch (plan) { switch (plan) {
case 0: return 'Free'; case 0: return t('debrid.plan_free');
case 1: return 'Essential ($3/mo)'; case 1: return t('debrid.plan_essential');
case 2: return 'Pro ($10/mo)'; case 2: return t('debrid.plan_pro');
case 3: return 'Standard ($5/mo)'; case 3: return t('debrid.plan_standard');
default: return 'Unknown'; default: return t('debrid.plan_unknown');
} }
}; };
@ -687,6 +688,7 @@ const createStyles = (colors: any) => StyleSheet.create({
}); });
const DebridIntegrationScreen = () => { const DebridIntegrationScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const colors = currentTheme.colors; const colors = currentTheme.colors;
@ -831,9 +833,9 @@ const DebridIntegrationScreen = () => {
// Torbox handlers // Torbox handlers
const handleConnect = async () => { const handleConnect = async () => {
if (!apiKey.trim()) { if (!apiKey.trim()) {
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Please enter a valid API Key'); setAlertMessage(t('debrid.error_api_required'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
return; return;
} }
@ -860,15 +862,15 @@ const DebridIntegrationScreen = () => {
setConfig(newConfig); setConfig(newConfig);
setApiKey(''); setApiKey('');
setAlertTitle('Success'); setAlertTitle(t('common.success'));
setAlertMessage('Torbox addon connected successfully!'); setAlertMessage(t('debrid.connected_title'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} catch (error) { } catch (error) {
logger.error('Failed to install Torbox addon:', error); logger.error('Failed to install Torbox addon:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Failed to connect addon. Please check your API Key and try again.'); setAlertMessage(t('addons.install_error'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setLoading(false); setLoading(false);
@ -888,12 +890,12 @@ const DebridIntegrationScreen = () => {
}; };
const handleDisconnect = async () => { const handleDisconnect = async () => {
setAlertTitle('Disconnect Torbox'); setAlertTitle(t('debrid.alert_disconnect_title'));
setAlertMessage('Are you sure you want to disconnect Torbox? This will remove the addon and clear your saved API key.'); setAlertMessage(t('debrid.alert_disconnect_msg'));
setAlertActions([ setAlertActions([
{ label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } }, { label: t('common.cancel'), onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } },
{ {
label: 'Disconnect', label: t('debrid.disconnect_button'),
onPress: async () => { onPress: async () => {
setAlertVisible(false); setAlertVisible(false);
setLoading(true); setLoading(true);
@ -913,15 +915,15 @@ const DebridIntegrationScreen = () => {
setConfig(null); setConfig(null);
setUserData(null); setUserData(null);
setAlertTitle('Success'); setAlertTitle(t('common.success'));
setAlertMessage('Torbox disconnected successfully'); setAlertMessage(t('debrid.alert_disconnect_success', 'Torbox disconnected successfully'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} catch (error) { } catch (error) {
logger.error('Failed to disconnect Torbox:', error); logger.error('Failed to disconnect Torbox:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Failed to disconnect Torbox'); setAlertMessage(t('debrid.alert_disconnect_error', 'Failed to disconnect Torbox'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setLoading(false); setLoading(false);
@ -1007,9 +1009,9 @@ const DebridIntegrationScreen = () => {
const handleInstallTorrentio = async () => { const handleInstallTorrentio = async () => {
// Check if API key is provided // Check if API key is provided
if (!torrentioConfig.debridApiKey.trim()) { if (!torrentioConfig.debridApiKey.trim()) {
setAlertTitle('API Key Required'); setAlertTitle(t('debrid.error_api_required'));
setAlertMessage('Please enter your debrid service API key to install Torrentio.'); setAlertMessage(t('debrid.error_api_required_desc'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
return; return;
} }
@ -1042,15 +1044,15 @@ const DebridIntegrationScreen = () => {
await mmkvStorage.setItem(TORRENTIO_CONFIG_KEY, JSON.stringify(newConfig)); await mmkvStorage.setItem(TORRENTIO_CONFIG_KEY, JSON.stringify(newConfig));
setTorrentioConfig(newConfig); setTorrentioConfig(newConfig);
setAlertTitle('Success'); setAlertTitle(t('common.success'));
setAlertMessage('Torrentio addon installed successfully!'); setAlertMessage(t('debrid.success_installed'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} catch (error) { } catch (error) {
logger.error('Failed to install Torrentio addon:', error); logger.error('Failed to install Torrentio addon:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Failed to install Torrentio addon. Please try again.'); setAlertMessage(t('addons.install_error'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setTorrentioLoading(false); setTorrentioLoading(false);
@ -1058,12 +1060,12 @@ const DebridIntegrationScreen = () => {
}; };
const handleRemoveTorrentio = async () => { const handleRemoveTorrentio = async () => {
setAlertTitle('Remove Torrentio'); setAlertTitle(t('debrid.remove_button'));
setAlertMessage('Are you sure you want to remove the Torrentio addon?'); setAlertMessage(t('addons.uninstall_message', { name: 'Torrentio' }));
setAlertActions([ setAlertActions([
{ label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } }, { label: t('common.cancel'), onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } },
{ {
label: 'Remove', label: t('debrid.remove_button'),
onPress: async () => { onPress: async () => {
setAlertVisible(false); setAlertVisible(false);
setTorrentioLoading(true); setTorrentioLoading(true);
@ -1087,15 +1089,15 @@ const DebridIntegrationScreen = () => {
await mmkvStorage.setItem(TORRENTIO_CONFIG_KEY, JSON.stringify(newConfig)); await mmkvStorage.setItem(TORRENTIO_CONFIG_KEY, JSON.stringify(newConfig));
setTorrentioConfig(newConfig); setTorrentioConfig(newConfig);
setAlertTitle('Success'); setAlertTitle(t('common.success'));
setAlertMessage('Torrentio addon removed successfully'); setAlertMessage(t('debrid.success_removed'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} catch (error) { } catch (error) {
logger.error('Failed to remove Torrentio:', error); logger.error('Failed to remove Torrentio:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Failed to remove Torrentio addon'); setAlertMessage(t('addons.uninstall_error', 'Failed to remove Torrentio addon'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setTorrentioLoading(false); setTorrentioLoading(false);
@ -1114,14 +1116,14 @@ const DebridIntegrationScreen = () => {
<> <>
<View style={styles.statusCard}> <View style={styles.statusCard}>
<View style={styles.statusRow}> <View style={styles.statusRow}>
<Text style={styles.statusLabel}>Status</Text> <Text style={styles.statusLabel}>{t('common.status')}</Text>
<Text style={[styles.statusValue, styles.statusConnected]}>Connected</Text> <Text style={[styles.statusValue, styles.statusConnected]}>{t('debrid.status_connected')}</Text>
</View> </View>
<View style={styles.divider} /> <View style={styles.divider} />
<View style={styles.statusRow}> <View style={styles.statusRow}>
<Text style={styles.statusLabel}>Enable Addon</Text> <Text style={styles.statusLabel}>{t('debrid.enable_addon')}</Text>
<Switch <Switch
value={config.isEnabled} value={config.isEnabled}
onValueChange={handleToggleEnabled} onValueChange={handleToggleEnabled}
@ -1138,28 +1140,28 @@ const DebridIntegrationScreen = () => {
disabled={loading} disabled={loading}
> >
<Text style={styles.buttonText}> <Text style={styles.buttonText}>
{loading ? 'Disconnecting...' : 'Disconnect & Remove'} {loading ? t('debrid.disconnect_loading') : t('debrid.disconnect_button')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{userData && ( {userData && (
<View style={styles.userDataCard}> <View style={styles.userDataCard}>
<View style={styles.userDataHeader}> <View style={styles.userDataHeader}>
<Text style={styles.userDataTitle}>Account Information</Text> <Text style={styles.userDataTitle}>{t('debrid.account_info')}</Text>
{userDataLoading && ( {userDataLoading && (
<ActivityIndicator size="small" color={colors.primary} /> <ActivityIndicator size="small" color={colors.primary} />
)} )}
</View> </View>
<View style={styles.userDataRow}> <View style={styles.userDataRow}>
<Text style={styles.userDataLabel}>Email</Text> <Text style={styles.userDataLabel}>{t('common.email')}</Text>
<Text style={styles.userDataValue} numberOfLines={1}> <Text style={styles.userDataValue} numberOfLines={1}>
{userData.base_email || userData.email} {userData.base_email || userData.email}
</Text> </Text>
</View> </View>
<View style={styles.userDataRow}> <View style={styles.userDataRow}>
<Text style={styles.userDataLabel}>Plan</Text> <Text style={styles.userDataLabel}>{t('debrid.plan')}</Text>
<View style={[ <View style={[
styles.planBadge, styles.planBadge,
userData.plan === 0 ? styles.planBadgeFree : styles.planBadgePaid userData.plan === 0 ? styles.planBadgeFree : styles.planBadgePaid
@ -1168,24 +1170,24 @@ const DebridIntegrationScreen = () => {
styles.planBadgeText, styles.planBadgeText,
userData.plan === 0 ? styles.planBadgeTextFree : styles.planBadgeTextPaid userData.plan === 0 ? styles.planBadgeTextFree : styles.planBadgeTextPaid
]}> ]}>
{getPlanName(userData.plan)} {getPlanName(userData.plan, t)}
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.userDataRow}> <View style={styles.userDataRow}>
<Text style={styles.userDataLabel}>Status</Text> <Text style={styles.userDataLabel}>{t('common.status')}</Text>
<Text style={[ <Text style={[
styles.userDataValue, styles.userDataValue,
{ color: userData.is_subscribed ? (colors.success || '#4CAF50') : colors.mediumEmphasis } { color: userData.is_subscribed ? (colors.success || '#4CAF50') : colors.mediumEmphasis }
]}> ]}>
{userData.is_subscribed ? 'Active' : 'Free'} {userData.is_subscribed ? t('debrid.status_active') : t('debrid.plan_free')}
</Text> </Text>
</View> </View>
{userData.premium_expires_at && ( {userData.premium_expires_at && (
<View style={styles.userDataRow}> <View style={styles.userDataRow}>
<Text style={styles.userDataLabel}>Expires</Text> <Text style={styles.userDataLabel}>{t('debrid.expires')}</Text>
<Text style={styles.userDataValue}> <Text style={styles.userDataValue}>
{new Date(userData.premium_expires_at).toLocaleDateString()} {new Date(userData.premium_expires_at).toLocaleDateString()}
</Text> </Text>
@ -1193,7 +1195,7 @@ const DebridIntegrationScreen = () => {
)} )}
<View style={styles.userDataRow}> <View style={styles.userDataRow}>
<Text style={styles.userDataLabel}>Downloaded</Text> <Text style={styles.userDataLabel}>{t('debrid.downloaded')}</Text>
<Text style={styles.userDataValue}> <Text style={styles.userDataValue}>
{(userData.total_downloaded / (1024 * 1024 * 1024)).toFixed(2)} GB {(userData.total_downloaded / (1024 * 1024 * 1024)).toFixed(2)} GB
</Text> </Text>
@ -1202,40 +1204,40 @@ const DebridIntegrationScreen = () => {
)} )}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}> Connected to TorBox</Text> <Text style={styles.sectionTitle}>{t('debrid.connected_title')}</Text>
<Text style={styles.sectionText}> <Text style={styles.sectionText}>
Your TorBox addon is active and providing premium streams.{config.isEnabled ? '' : ' (Currently disabled)'} {t('debrid.connected_desc')}
</Text> </Text>
</View> </View>
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Configure Addon</Text> <Text style={styles.sectionTitle}>{t('debrid.configure_title')}</Text>
<Text style={styles.sectionText}> <Text style={styles.sectionText}>
Customize your streaming experience. Sort by quality, filter file sizes, and manage other integration settings. {t('debrid.configure_desc')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.subscribeButton} style={styles.subscribeButton}
onPress={() => Linking.openURL('https://torbox.app/settings?section=integration-settings')} onPress={() => Linking.openURL('https://torbox.app/settings?section=integration-settings')}
> >
<Text style={styles.subscribeButtonText}>Open Settings</Text> <Text style={styles.subscribeButtonText}>{t('debrid.open_settings')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</> </>
) : ( ) : (
<> <>
<Text style={styles.description}> <Text style={styles.description}>
Unlock 4K high-quality streams and lightning-fast speeds by integrating Torbox. Enter your API Key below to instantly upgrade your streaming experience. {t('debrid.description_torbox')}
</Text> </Text>
<TouchableOpacity onPress={() => Linking.openURL('https://guides.viren070.me/stremio/technical-details#debrid-services')} style={styles.guideLink}> <TouchableOpacity onPress={() => Linking.openURL('https://guides.viren070.me/stremio/technical-details#debrid-services')} style={styles.guideLink}>
<Text style={styles.guideLinkText}>What is a Debrid Service?</Text> <Text style={styles.guideLinkText}>{t('debrid.what_is_debrid')}</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<Text style={styles.label}>Torbox API Key</Text> <Text style={styles.label}>{t('debrid.api_key_label')}</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="Enter your API Key" placeholder={t('debrid.enter_api_key')}
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
value={apiKey} value={apiKey}
onChangeText={setApiKey} onChangeText={setApiKey}
@ -1251,24 +1253,24 @@ const DebridIntegrationScreen = () => {
disabled={loading} disabled={loading}
> >
<Text style={styles.connectButtonText}> <Text style={styles.connectButtonText}>
{loading ? 'Connecting...' : 'Connect & Install'} {loading ? t('debrid.connecting') : t('debrid.connect_button')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Unlock Premium Speeds</Text> <Text style={styles.sectionTitle}>{t('debrid.unlock_speeds_title')}</Text>
<Text style={styles.sectionText}> <Text style={styles.sectionText}>
Get a Torbox subscription to access cached high-quality streams with zero buffering. {t('debrid.unlock_speeds_desc')}
</Text> </Text>
<TouchableOpacity style={styles.subscribeButton} onPress={openSubscription}> <TouchableOpacity style={styles.subscribeButton} onPress={openSubscription}>
<Text style={styles.subscribeButtonText}>Get Subscription</Text> <Text style={styles.subscribeButtonText}>{t('debrid.get_subscription')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</> </>
)} )}
<View style={[styles.logoContainer, { marginTop: 60 }]}> <View style={[styles.logoContainer, { marginTop: 60 }]}>
<Text style={styles.poweredBy}>Powered by</Text> <Text style={styles.poweredBy}>{t('debrid.powered_by')}</Text>
<View style={styles.logoRow}> <View style={styles.logoRow}>
<Image <Image
source={{ uri: 'https://torbox.app/assets/logo-bb7a9579.svg' }} source={{ uri: 'https://torbox.app/assets/logo-bb7a9579.svg' }}
@ -1277,7 +1279,7 @@ const DebridIntegrationScreen = () => {
/> />
<Text style={styles.logoText}>TorBox</Text> <Text style={styles.logoText}>TorBox</Text>
</View> </View>
<Text style={styles.disclaimer}>Nuvio is not affiliated with Torbox in any way.</Text> <Text style={styles.disclaimer}>{t('debrid.disclaimer_torbox')}</Text>
</View> </View>
</> </>
); );
@ -1290,34 +1292,34 @@ const DebridIntegrationScreen = () => {
const renderTorrentioTab = () => ( const renderTorrentioTab = () => (
<> <>
<Text style={styles.description}> <Text style={styles.description}>
Configure Torrentio to get torrent streams for movies and TV shows. A debrid service is required to stream content. {t('debrid.description_torrentio')}
</Text> </Text>
{torrentioConfig.isInstalled && ( {torrentioConfig.isInstalled && (
<View style={styles.installedBadge}> <View style={styles.installedBadge}>
<Text style={styles.installedBadgeText}> INSTALLED</Text> <Text style={styles.installedBadgeText}>{t('debrid.installed_badge')}</Text>
</View> </View>
)} )}
{/* TorBox Promotion Card */} {/* TorBox Promotion Card */}
{!torrentioConfig.debridApiKey && ( {!torrentioConfig.debridApiKey && (
<View style={styles.promoCard}> <View style={styles.promoCard}>
<Text style={styles.promoTitle}> Need a Debrid Service?</Text> <Text style={styles.promoTitle}>{t('debrid.promo_title')}</Text>
<Text style={styles.promoText}> <Text style={styles.promoText}>
Get TorBox for lightning-fast 4K streaming with zero buffering. Premium cached torrents and instant downloads. {t('debrid.promo_desc')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.promoButton} style={styles.promoButton}
onPress={() => Linking.openURL('https://torbox.app/subscription?referral=493192f2-6403-440f-b414-768f72222ec7')} onPress={() => Linking.openURL('https://torbox.app/subscription?referral=493192f2-6403-440f-b414-768f72222ec7')}
> >
<Text style={styles.promoButtonText}>Get TorBox Subscription</Text> <Text style={styles.promoButtonText}>{t('debrid.promo_button')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
{/* Debrid Service Selection */} {/* Debrid Service Selection */}
<View style={styles.configSection}> <View style={styles.configSection}>
<Text style={styles.configSectionTitle}>Debrid Service *</Text> <Text style={styles.configSectionTitle}>{t('debrid.service_label')}</Text>
<View style={styles.pickerContainer}> <View style={styles.pickerContainer}>
{TORRENTIO_DEBRID_SERVICES.map((service: any) => ( {TORRENTIO_DEBRID_SERVICES.map((service: any) => (
<TouchableOpacity <TouchableOpacity
@ -1341,7 +1343,7 @@ const DebridIntegrationScreen = () => {
{/* Debrid API Key */} {/* Debrid API Key */}
<View style={styles.configSection}> <View style={styles.configSection}>
<Text style={styles.configSectionTitle}>API Key *</Text> <Text style={styles.configSectionTitle}>{t('debrid.api_key_label')}</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder={`Enter your ${TORRENTIO_DEBRID_SERVICES.find((d: any) => d.id === torrentioConfig.debridService)?.name || 'Debrid'} API Key`} placeholder={`Enter your ${TORRENTIO_DEBRID_SERVICES.find((d: any) => d.id === torrentioConfig.debridService)?.name || 'Debrid'} API Key`}
@ -1360,9 +1362,9 @@ const DebridIntegrationScreen = () => {
onPress={() => toggleSection('sorting')} onPress={() => toggleSection('sorting')}
> >
<View> <View>
<Text style={styles.accordionHeaderText}>Sorting</Text> <Text style={styles.accordionHeaderText}>{t('debrid.sorting_label')}</Text>
<Text style={styles.accordionSubtext}> <Text style={styles.accordionSubtext}>
{TORRENTIO_SORT_OPTIONS.find(o => o.id === torrentioConfig.sort)?.name || 'By quality'} {TORRENTIO_SORT_OPTIONS.find(o => o.id === torrentioConfig.sort)?.name || t('debrid.by_quality', 'By quality')}
</Text> </Text>
</View> </View>
<Feather name={expandedSections.sorting ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} /> <Feather name={expandedSections.sorting ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} />
@ -1391,9 +1393,9 @@ const DebridIntegrationScreen = () => {
onPress={() => toggleSection('qualityFilter')} onPress={() => toggleSection('qualityFilter')}
> >
<View> <View>
<Text style={styles.accordionHeaderText}>Exclude Qualities</Text> <Text style={styles.accordionHeaderText}>{t('debrid.exclude_qualities')}</Text>
<Text style={styles.accordionSubtext}> <Text style={styles.accordionSubtext}>
{torrentioConfig.qualityFilter.length > 0 ? `${torrentioConfig.qualityFilter.length} excluded` : 'None excluded'} {torrentioConfig.qualityFilter.length > 0 ? t('debrid.excluded_count', { count: torrentioConfig.qualityFilter.length, defaultValue: '{{count}} excluded' }) : t('debrid.none_excluded', 'None excluded')}
</Text> </Text>
</View> </View>
<Feather name={expandedSections.qualityFilter ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} /> <Feather name={expandedSections.qualityFilter ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} />
@ -1422,9 +1424,9 @@ const DebridIntegrationScreen = () => {
onPress={() => toggleSection('languages')} onPress={() => toggleSection('languages')}
> >
<View> <View>
<Text style={styles.accordionHeaderText}>Priority Languages</Text> <Text style={styles.accordionHeaderText}>{t('debrid.priority_languages')}</Text>
<Text style={styles.accordionSubtext}> <Text style={styles.accordionSubtext}>
{torrentioConfig.priorityLanguages.length > 0 ? `${torrentioConfig.priorityLanguages.length} selected` : 'No preference'} {torrentioConfig.priorityLanguages.length > 0 ? `${torrentioConfig.priorityLanguages.length} ${t('home_screen.selected')}` : t('debrid.no_preference', 'No preference')}
</Text> </Text>
</View> </View>
<Feather name={expandedSections.languages ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} /> <Feather name={expandedSections.languages ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} />
@ -1453,9 +1455,9 @@ const DebridIntegrationScreen = () => {
onPress={() => toggleSection('maxResults')} onPress={() => toggleSection('maxResults')}
> >
<View> <View>
<Text style={styles.accordionHeaderText}>Max Results</Text> <Text style={styles.accordionHeaderText}>{t('debrid.max_results')}</Text>
<Text style={styles.accordionSubtext}> <Text style={styles.accordionSubtext}>
{TORRENTIO_MAX_RESULTS.find(o => o.id === torrentioConfig.maxResults)?.name || 'All results'} {TORRENTIO_MAX_RESULTS.find(o => o.id === torrentioConfig.maxResults)?.name || t('debrid.all_results', 'All results')}
</Text> </Text>
</View> </View>
<Feather name={expandedSections.maxResults ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} /> <Feather name={expandedSections.maxResults ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} />
@ -1484,15 +1486,15 @@ const DebridIntegrationScreen = () => {
onPress={() => toggleSection('options')} onPress={() => toggleSection('options')}
> >
<View> <View>
<Text style={styles.accordionHeaderText}>Additional Options</Text> <Text style={styles.accordionHeaderText}>{t('debrid.additional_options')}</Text>
<Text style={styles.accordionSubtext}>Catalog & download settings</Text> <Text style={styles.accordionSubtext}>{t('debrid.catalog_download_settings', 'Catalog & download settings')}</Text>
</View> </View>
<Feather name={expandedSections.options ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} /> <Feather name={expandedSections.options ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} />
</TouchableOpacity> </TouchableOpacity>
{expandedSections.options && ( {expandedSections.options && (
<View style={styles.accordionContent}> <View style={styles.accordionContent}>
<View style={styles.switchRow}> <View style={styles.switchRow}>
<Text style={styles.switchLabel}>Don't show download links</Text> <Text style={styles.switchLabel}>{t('debrid.no_download_links')}</Text>
<Switch <Switch
value={torrentioConfig.noDownloadLinks} value={torrentioConfig.noDownloadLinks}
onValueChange={(val) => setTorrentioConfig(prev => ({ ...prev, noDownloadLinks: val }))} onValueChange={(val) => setTorrentioConfig(prev => ({ ...prev, noDownloadLinks: val }))}
@ -1501,7 +1503,7 @@ const DebridIntegrationScreen = () => {
/> />
</View> </View>
<View style={styles.switchRow}> <View style={styles.switchRow}>
<Text style={styles.switchLabel}>Don't show debrid catalog</Text> <Text style={styles.switchLabel}>{t('debrid.no_debrid_catalog')}</Text>
<Switch <Switch
value={torrentioConfig.noCatalog} value={torrentioConfig.noCatalog}
onValueChange={(val) => setTorrentioConfig(prev => ({ ...prev, noCatalog: val }))} onValueChange={(val) => setTorrentioConfig(prev => ({ ...prev, noCatalog: val }))}
@ -1532,7 +1534,7 @@ const DebridIntegrationScreen = () => {
disabled={torrentioLoading} disabled={torrentioLoading}
> >
<Text style={styles.connectButtonText}> <Text style={styles.connectButtonText}>
{torrentioLoading ? 'Updating...' : 'Update Configuration'} {torrentioLoading ? t('debrid.updating') : t('debrid.update_button')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@ -1540,7 +1542,7 @@ const DebridIntegrationScreen = () => {
onPress={handleRemoveTorrentio} onPress={handleRemoveTorrentio}
disabled={torrentioLoading} disabled={torrentioLoading}
> >
<Text style={styles.buttonText}>Remove Torrentio</Text> <Text style={styles.buttonText}>{t('debrid.remove_button')}</Text>
</TouchableOpacity> </TouchableOpacity>
</> </>
) : ( ) : (
@ -1550,14 +1552,14 @@ const DebridIntegrationScreen = () => {
disabled={torrentioLoading} disabled={torrentioLoading}
> >
<Text style={styles.connectButtonText}> <Text style={styles.connectButtonText}>
{torrentioLoading ? 'Installing...' : 'Install Torrentio'} {torrentioLoading ? t('debrid.installing') : t('debrid.install_button')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
<Text style={[styles.disclaimer, { marginTop: 24, marginBottom: 40 }]}> <Text style={[styles.disclaimer, { marginTop: 24, marginBottom: 40 }]}>
Nuvio is not affiliated with Torrentio in any way. {t('debrid.disclaimer_torrentio')}
</Text> </Text>
</> </>
); );
@ -1584,7 +1586,7 @@ const DebridIntegrationScreen = () => {
> >
<Feather name="arrow-left" size={24} color={colors.white} /> <Feather name="arrow-left" size={24} color={colors.white} />
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.headerTitle}>Debrid Integration</Text> <Text style={styles.headerTitle}>{t('debrid.title')}</Text>
</View> </View>
{/* Tab Selector */} {/* Tab Selector */}
@ -1594,7 +1596,7 @@ const DebridIntegrationScreen = () => {
onPress={() => setActiveTab('torbox')} onPress={() => setActiveTab('torbox')}
> >
<Text style={[styles.tabText, activeTab === 'torbox' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'torbox' && styles.activeTabText]}>
TorBox {t('debrid.tab_torbox')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@ -1602,7 +1604,7 @@ const DebridIntegrationScreen = () => {
onPress={() => setActiveTab('torrentio')} onPress={() => setActiveTab('torrentio')}
> >
<Text style={[styles.tabText, activeTab === 'torrentio' && styles.activeTabText]}> <Text style={[styles.tabText, activeTab === 'torrentio' && styles.activeTabText]}>
Torrentio {t('debrid.tab_torrentio')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View file

@ -30,6 +30,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { useDownloads } from '../contexts/DownloadsContext'; import { useDownloads } from '../contexts/DownloadsContext';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { useTranslation } from 'react-i18next';
import { VideoPlayerService } from '../services/videoPlayerService'; import { VideoPlayerService } from '../services/videoPlayerService';
import type { DownloadItem } from '../contexts/DownloadsContext'; import type { DownloadItem } from '../contexts/DownloadsContext';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
@ -65,6 +66,7 @@ const optimizePosterUrl = (poster: string | undefined | null): string => {
// Empty state component // Empty state component
const EmptyDownloadsState: React.FC<{ navigation: NavigationProp<RootStackParamList> }> = ({ navigation }) => { const EmptyDownloadsState: React.FC<{ navigation: NavigationProp<RootStackParamList> }> = ({ navigation }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
return ( return (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
@ -76,10 +78,10 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp<RootStackParamL
/> />
</View> </View>
<Text style={[styles.emptyTitle, { color: currentTheme.colors.text }]}> <Text style={[styles.emptyTitle, { color: currentTheme.colors.text }]}>
No Downloads Yet {t('downloads.no_downloads')}
</Text> </Text>
<Text style={[styles.emptySubtitle, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.emptySubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
Downloaded content will appear here for offline viewing {t('downloads.no_downloads_desc')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]} style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]}
@ -88,7 +90,7 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp<RootStackParamL
}} }}
> >
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.background }]}> <Text style={[styles.exploreButtonText, { color: currentTheme.colors.background }]}>
Explore Content {t('downloads.explore')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -105,6 +107,7 @@ const DownloadItemComponent: React.FC<{
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings(); const { settings } = useSettings();
const { showSuccess, showInfo } = useToast(); const { showSuccess, showInfo } = useToast();
const { t } = useTranslation();
const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null); const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null);
const borderRadius = settings.posterBorderRadius ?? 12; const borderRadius = settings.posterBorderRadius ?? 12;
@ -121,15 +124,15 @@ const DownloadItemComponent: React.FC<{
if (item.status === 'completed' && item.fileUri) { if (item.status === 'completed' && item.fileUri) {
Clipboard.setString(item.fileUri); Clipboard.setString(item.fileUri);
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
showSuccess('Path Copied', 'Local file path copied to clipboard'); showSuccess(t('downloads.path_copied'), t('downloads.path_copied_desc'));
} else { } else {
Alert.alert('Copied', 'Local file path copied to clipboard'); Alert.alert(t('downloads.copied'), t('downloads.path_copied_desc'));
} }
} else if (item.status !== 'completed') { } else if (item.status !== 'completed') {
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
showInfo('Download Incomplete', 'Download is not complete yet'); showInfo(t('downloads.incomplete'), t('downloads.incomplete_desc'));
} else { } else {
Alert.alert('Not Available', 'The local file path is available only after the download is complete.'); Alert.alert(t('downloads.not_available'), t('downloads.not_available_desc'));
} }
} }
}, [item.status, item.fileUri, showSuccess, showInfo]); }, [item.status, item.fileUri, showSuccess, showInfo]);
@ -163,17 +166,17 @@ const DownloadItemComponent: React.FC<{
switch (item.status) { switch (item.status) {
case 'downloading': case 'downloading':
const eta = item.etaSeconds ? `${Math.ceil(item.etaSeconds / 60)}m` : undefined; const eta = item.etaSeconds ? `${Math.ceil(item.etaSeconds / 60)}m` : undefined;
return eta ? `Downloading • ${eta}` : 'Downloading'; return eta ? `${t('downloads.status_downloading')}${eta}` : t('downloads.status_downloading');
case 'completed': case 'completed':
return 'Completed'; return t('downloads.status_completed');
case 'paused': case 'paused':
return 'Paused'; return t('downloads.status_paused');
case 'error': case 'error':
return 'Error'; return t('downloads.status_error');
case 'queued': case 'queued':
return 'Queued'; return t('downloads.status_queued');
default: default:
return 'Unknown'; return t('downloads.status_unknown');
} }
}; };
@ -257,7 +260,7 @@ const DownloadItemComponent: React.FC<{
{/* Provider + quality row */} {/* Provider + quality row */}
<View style={styles.providerRow}> <View style={styles.providerRow}>
<Text style={[styles.providerText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.providerText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.providerName || 'Provider'} {item.providerName || t('downloads.provider')}
</Text> </Text>
</View> </View>
{/* Status row */} {/* Status row */}
@ -283,7 +286,7 @@ const DownloadItemComponent: React.FC<{
color={currentTheme.colors.warning || '#FF9500'} color={currentTheme.colors.warning || '#FF9500'}
/> />
<Text style={[styles.warningText, { color: currentTheme.colors.warning || '#FF9500' }]}> <Text style={[styles.warningText, { color: currentTheme.colors.warning || '#FF9500' }]}>
May not play - streaming playlist {t('downloads.streaming_playlist_warning')}
</Text> </Text>
</View> </View>
)} )}
@ -307,7 +310,7 @@ const DownloadItemComponent: React.FC<{
</Text> </Text>
{item.etaSeconds && item.status === 'downloading' && ( {item.etaSeconds && item.status === 'downloading' && (
<Text style={[styles.etaText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.etaText, { color: currentTheme.colors.mediumEmphasis }]}>
{Math.ceil(item.etaSeconds / 60)}m remaining {Math.ceil(item.etaSeconds / 60)}m {t('downloads.remaining')}
</Text> </Text>
)} )}
</View> </View>
@ -350,6 +353,7 @@ const DownloadsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings(); const { settings } = useSettings();
const { t } = useTranslation();
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads(); const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
const { showSuccess, showInfo } = useToast(); const { showSuccess, showInfo } = useToast();
@ -409,7 +413,7 @@ const DownloadsScreen: React.FC = () => {
const handleDownloadPress = useCallback(async (item: DownloadItem) => { const handleDownloadPress = useCallback(async (item: DownloadItem) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (item.status !== 'completed') { if (item.status !== 'completed') {
Alert.alert('Download not ready', 'Please wait until the download completes.'); Alert.alert(t('downloads.not_ready'), t('downloads.not_ready_desc'));
return; return;
} }
const uri = (item as any).fileUri || (item as any).sourceUrl; const uri = (item as any).fileUri || (item as any).sourceUrl;
@ -636,7 +640,7 @@ const DownloadsScreen: React.FC = () => {
{/* ScreenHeader Component */} {/* ScreenHeader Component */}
<ScreenHeader <ScreenHeader
title="Downloads" title={t('downloads.title')}
rightActionComponent={ rightActionComponent={
<TouchableOpacity <TouchableOpacity
style={styles.helpButton} style={styles.helpButton}
@ -654,10 +658,10 @@ const DownloadsScreen: React.FC = () => {
> >
{downloads.length > 0 && ( {downloads.length > 0 && (
<View style={styles.filterContainer}> <View style={styles.filterContainer}>
{renderFilterButton('all', 'All', stats.total)} {renderFilterButton('all', t('downloads.filter_all'), stats.total)}
{renderFilterButton('downloading', 'Active', stats.downloading)} {renderFilterButton('downloading', t('downloads.filter_active'), stats.downloading)}
{renderFilterButton('completed', 'Done', stats.completed)} {renderFilterButton('completed', t('downloads.filter_done'), stats.completed)}
{renderFilterButton('paused', 'Paused', stats.paused)} {renderFilterButton('paused', t('downloads.filter_paused'), stats.paused)}
</View> </View>
)} )}
</ScreenHeader> </ScreenHeader>
@ -697,10 +701,10 @@ const DownloadsScreen: React.FC = () => {
color={currentTheme.colors.mediumEmphasis} color={currentTheme.colors.mediumEmphasis}
/> />
<Text style={[styles.emptyFilterTitle, { color: currentTheme.colors.text }]}> <Text style={[styles.emptyFilterTitle, { color: currentTheme.colors.text }]}>
No {selectedFilter} downloads {t('downloads.no_filter_results', { filter: selectedFilter })}
</Text> </Text>
<Text style={[styles.emptyFilterSubtitle, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.emptyFilterSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
Try selecting a different filter {t('downloads.try_different_filter')}
</Text> </Text>
</View> </View>
)} )}
@ -710,19 +714,22 @@ const DownloadsScreen: React.FC = () => {
{/* Help Alert */} {/* Help Alert */}
<CustomAlert <CustomAlert
visible={showHelpAlert} visible={showHelpAlert}
title="Download Limitations" title={t('downloads.limitations_title')}
message="• Files smaller than 1MB are typically M3U8 streaming playlists and cannot be downloaded for offline viewing. These only work with online streaming and contain links to video segments, not the actual video content." message={t('downloads.limitations_msg')}
onClose={() => setShowHelpAlert(false)} onClose={() => setShowHelpAlert(false)}
/> />
{/* Remove Download Confirmation */} {/* Remove Download Confirmation */}
<CustomAlert <CustomAlert
visible={showRemoveAlert} visible={showRemoveAlert}
title="Remove Download" title={t('downloads.remove_title')}
message={pendingRemoveItem ? `Remove \"${pendingRemoveItem.title}\"${pendingRemoveItem.type === 'series' && pendingRemoveItem.season && pendingRemoveItem.episode ? ` S${String(pendingRemoveItem.season).padStart(2, '0')}E${String(pendingRemoveItem.episode).padStart(2, '0')}` : ''}?` : 'Remove this download?'} message={pendingRemoveItem ? t('downloads.remove_confirm', {
title: pendingRemoveItem.title,
season_episode: pendingRemoveItem.type === 'series' && pendingRemoveItem.season && pendingRemoveItem.episode ? ` S${String(pendingRemoveItem.season).padStart(2, '0')}E${String(pendingRemoveItem.episode).padStart(2, '0')}` : ''
}) : t('downloads.remove_confirm', { title: 'this download', season_episode: '' })}
actions={[ actions={[
{ label: 'Cancel', onPress: () => setShowRemoveAlert(false) }, { label: t('downloads.cancel'), onPress: () => setShowRemoveAlert(false) },
{ label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: {} }, { label: t('downloads.remove'), onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: {} },
]} ]}
onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }} onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }}
/> />

View file

@ -20,6 +20,7 @@ import { MaterialIcons } from '@expo/vector-icons';
import { colors } from '../styles/colors'; import { colors } from '../styles/colors';
import { catalogService, StreamingAddon } from '../services/catalogService'; import { catalogService, StreamingAddon } from '../services/catalogService';
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames'; import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
import { useTranslation } from 'react-i18next';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -31,6 +32,7 @@ interface CatalogItem {
} }
const HeroCatalogsScreen: React.FC = () => { const HeroCatalogsScreen: React.FC = () => {
const { t } = useTranslation();
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme(); const systemColorScheme = useColorScheme();
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode; const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
@ -60,7 +62,7 @@ const HeroCatalogsScreen: React.FC = () => {
// Refresh selected catalogs when settings change // Refresh selected catalogs when settings change
setSelectedCatalogs(settings.selectedHeroCatalogs || []); setSelectedCatalogs(settings.selectedHeroCatalogs || []);
}); });
return unsubscribe; return unsubscribe;
}, [settings.selectedHeroCatalogs]); }, [settings.selectedHeroCatalogs]);
@ -86,10 +88,10 @@ const HeroCatalogsScreen: React.FC = () => {
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
// First update the settings // First update the settings
updateSetting('selectedHeroCatalogs', selectedCatalogs); updateSetting('selectedHeroCatalogs', selectedCatalogs);
// Show the confirmation indicator // Show the confirmation indicator
setShowSavedIndicator(true); setShowSavedIndicator(true);
// Short delay before navigating back to allow settings to save // Short delay before navigating back to allow settings to save
// and the user to see the confirmation message // and the user to see the confirmation message
setTimeout(() => { setTimeout(() => {
@ -108,7 +110,7 @@ const HeroCatalogsScreen: React.FC = () => {
try { try {
const addons = await catalogService.getAllAddons(); const addons = await catalogService.getAllAddons();
const catalogItems: CatalogItem[] = []; const catalogItems: CatalogItem[] = [];
addons.forEach(addon => { addons.forEach(addon => {
if (addon.catalogs && addon.catalogs.length > 0) { if (addon.catalogs && addon.catalogs.length > 0) {
addon.catalogs.forEach(catalog => { addon.catalogs.forEach(catalog => {
@ -121,19 +123,19 @@ const HeroCatalogsScreen: React.FC = () => {
}); });
} }
}); });
setCatalogs(catalogItems); setCatalogs(catalogItems);
} catch (error) { } catch (error) {
if (__DEV__) console.error('Failed to load catalogs:', error); if (__DEV__) console.error('Failed to load catalogs:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Failed to load catalogs'); setAlertMessage(t('home_screen.hero_catalogs.error_load'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
loadCatalogs(); loadCatalogs();
}, []); }, []);
@ -172,22 +174,22 @@ const HeroCatalogsScreen: React.FC = () => {
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} /> <StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.backButton}> <TouchableOpacity onPress={handleBack} style={styles.backButton}>
<MaterialIcons <MaterialIcons
name="arrow-back" name="arrow-back"
size={24} size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark} color={isDarkMode ? colors.highEmphasis : colors.textDark}
/> />
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}> <Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Hero Section Catalogs {t('home_screen.hero_catalogs.title')}
</Text> </Text>
</View> </View>
{/* Saved indicator */} {/* Saved indicator */}
<Animated.View <Animated.View
style={[ style={[
styles.savedIndicator, styles.savedIndicator,
{ {
opacity: fadeAnim, opacity: fadeAnim,
backgroundColor: isDarkMode ? 'rgba(0, 180, 150, 0.9)' : 'rgba(0, 180, 150, 0.9)' backgroundColor: isDarkMode ? 'rgba(0, 180, 150, 0.9)' : 'rgba(0, 180, 150, 0.9)'
} }
@ -195,47 +197,47 @@ const HeroCatalogsScreen: React.FC = () => {
pointerEvents="none" pointerEvents="none"
> >
<MaterialIcons name="check-circle" size={20} color="#FFFFFF" /> <MaterialIcons name="check-circle" size={20} color="#FFFFFF" />
<Text style={styles.savedIndicatorText}>Settings Saved</Text> <Text style={styles.savedIndicatorText}>{t('home_screen.hero_catalogs.settings_saved')}</Text>
</Animated.View> </Animated.View>
{loading || isLoadingCustomNames ? ( {loading || isLoadingCustomNames ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
<Text style={[styles.loadingText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}> <Text style={[styles.loadingText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
Loading catalogs... {t('common.loading')}
</Text> </Text>
</View> </View>
) : ( ) : (
<> <>
<View style={styles.actionBar}> <View style={styles.actionBar}>
<TouchableOpacity <TouchableOpacity
style={[styles.actionButton, { backgroundColor: isDarkMode ? colors.elevation2 : colors.white }]} style={[styles.actionButton, { backgroundColor: isDarkMode ? colors.elevation2 : colors.white }]}
onPress={handleSelectAll} onPress={handleSelectAll}
> >
<Text style={[styles.actionButtonText, { color: colors.primary }]}>Select All</Text> <Text style={[styles.actionButtonText, { color: colors.primary }]}>{t('home_screen.hero_catalogs.select_all')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.actionButton, { backgroundColor: isDarkMode ? colors.elevation2 : colors.white }]} style={[styles.actionButton, { backgroundColor: isDarkMode ? colors.elevation2 : colors.white }]}
onPress={handleSelectNone} onPress={handleSelectNone}
> >
<Text style={[styles.actionButtonText, { color: colors.primary }]}>Clear All</Text> <Text style={[styles.actionButtonText, { color: colors.primary }]}>{t('home_screen.hero_catalogs.clear_all')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.saveButton, { backgroundColor: colors.primary }]} style={[styles.saveButton, { backgroundColor: colors.primary }]}
onPress={handleSave} onPress={handleSave}
> >
<MaterialIcons name="save" size={16} color={colors.white} style={styles.saveIcon} /> <MaterialIcons name="save" size={16} color={colors.white} style={styles.saveIcon} />
<Text style={styles.saveButtonText}>Save</Text> <Text style={styles.saveButtonText}>{t('common.save')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.infoCard}> <View style={styles.infoCard}>
<Text style={[styles.infoText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}> <Text style={[styles.infoText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
Select which catalogs to display in the hero section. If none are selected, all catalogs will be used. Don't forget to press Save when you're done. {t('home_screen.hero_catalogs.info')}
</Text> </Text>
</View> </View>
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
@ -246,13 +248,13 @@ const HeroCatalogsScreen: React.FC = () => {
{addonName} {addonName}
</Text> </Text>
<View style={[ <View style={[
styles.catalogsContainer, styles.catalogsContainer,
{ backgroundColor: isDarkMode ? colors.elevation1 : colors.white } { backgroundColor: isDarkMode ? colors.elevation1 : colors.white }
]}> ]}>
{addonCatalogs.map(catalog => { {addonCatalogs.map(catalog => {
const [addonId, type, catalogId] = catalog.id.split(':'); const [addonId, type, catalogId] = catalog.id.split(':');
const displayName = getCustomName(addonId, type, catalogId, catalog.name); const displayName = getCustomName(addonId, type, catalogId, catalog.name);
return ( return (
<TouchableOpacity <TouchableOpacity
key={catalog.id} key={catalog.id}
@ -267,7 +269,7 @@ const HeroCatalogsScreen: React.FC = () => {
{displayName} {displayName}
</Text> </Text>
<Text style={[styles.catalogType, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}> <Text style={[styles.catalogType, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
{catalog.type === 'movie' ? 'Movies' : 'TV Shows'} {catalog.type === 'movie' ? t('home_screen.hero_catalogs.movies') : t('home_screen.hero_catalogs.tv_shows')}
</Text> </Text>
</View> </View>
<MaterialIcons <MaterialIcons
@ -284,14 +286,14 @@ const HeroCatalogsScreen: React.FC = () => {
</ScrollView> </ScrollView>
</> </>
)} )}
<CustomAlert <CustomAlert
visible={alertVisible} visible={alertVisible}
title={alertTitle} title={alertTitle}
message={alertMessage} message={alertMessage}
onClose={() => setAlertVisible(false)} onClose={() => setAlertVisible(false)}
actions={alertActions} actions={alertActions}
/> />
</SafeAreaView> </SafeAreaView>
); );
}; };

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
View, View,
Text, Text,
@ -44,7 +45,11 @@ import * as Haptics from 'expo-haptics';
import { tmdbService } from '../services/tmdbService'; import { tmdbService } from '../services/tmdbService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { storageService } from '../services/storageService'; import { storageService } from '../services/storageService';
import { getCatalogDisplayName, clearCustomNameCache } from '../utils/catalogNameUtils'; import {
getCatalogDisplayName,
getFormattedCatalogName,
clearCustomNameCache
} from '../utils/catalogNameUtils';
import { useHomeCatalogs } from '../hooks/useHomeCatalogs'; import { useHomeCatalogs } from '../hooks/useHomeCatalogs';
import { useFeaturedContent } from '../hooks/useFeaturedContent'; import { useFeaturedContent } from '../hooks/useFeaturedContent';
import { useSettings, settingsEmitter } from '../hooks/useSettings'; import { useSettings, settingsEmitter } from '../hooks/useSettings';
@ -94,13 +99,6 @@ type HomeScreenListItem =
| { type: 'welcome'; key: string } | { type: 'welcome'; key: string }
| { type: 'loadMore'; key: string }; | { type: 'loadMore'; key: string };
// Sample categories (real app would get these from API)
const SAMPLE_CATEGORIES: Category[] = [
{ id: 'movie', name: 'Movies' },
{ id: 'series', name: 'Series' },
{ id: 'channel', name: 'Channels' },
];
const SkeletonCatalog = React.memo(() => { const SkeletonCatalog = React.memo(() => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
return ( return (
@ -113,6 +111,7 @@ const SkeletonCatalog = React.memo(() => {
}); });
const HomeScreen = () => { const HomeScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
@ -277,21 +276,13 @@ const HomeScreen = () => {
const isCustom = displayName !== originalName; const isCustom = displayName !== originalName;
if (!isCustom) { if (!isCustom) {
// De-duplicate repeated words (case-insensitive) displayName = getFormattedCatalogName(
const words = displayName.split(' ').filter(Boolean); displayName,
const uniqueWords: string[] = []; catalog.type,
const seen = new Set<string>(); t('home.movies'),
for (const w of words) { t('home.tv_shows'),
const lw = w.toLowerCase(); t('home.channels')
if (!seen.has(lw)) { uniqueWords.push(w); seen.add(lw); } );
}
displayName = uniqueWords.join(' ');
// Append content type if not present
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
displayName = `${displayName} ${contentType}`;
}
} }
const catalogContent = { const catalogContent = {
@ -299,6 +290,7 @@ const HomeScreen = () => {
type: catalog.type, type: catalog.type,
id: catalog.id, id: catalog.id,
name: displayName, name: displayName,
originalName: originalName,
items items
}; };
@ -422,7 +414,7 @@ const HomeScreen = () => {
await mmkvStorage.removeItem('showLoginHintToastOnce'); await mmkvStorage.removeItem('showLoginHintToastOnce');
hideTimer = setTimeout(() => setHintVisible(false), 2000); hideTimer = setTimeout(() => setHintVisible(false), 2000);
// Also show a global toast for consistency across screens // Also show a global toast for consistency across screens
// showInfo('Sign In Available', 'You can sign in anytime from Settings → Account'); // showInfo(t('home.sign_in_available'), t('home.sign_in_desc'));
} }
} catch { } } catch { }
})(); })();
@ -813,7 +805,7 @@ const HomeScreen = () => {
> >
<MaterialIcons name="expand-more" size={20} color={currentTheme.colors.white} /> <MaterialIcons name="expand-more" size={20} color={currentTheme.colors.white} />
<Text style={[styles.loadMoreText, { color: currentTheme.colors.white }]}> <Text style={[styles.loadMoreText, { color: currentTheme.colors.white }]}>
Load More Catalogs {t('home.load_more_catalogs')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -835,14 +827,14 @@ const HomeScreen = () => {
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} /> <MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}> <Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available {t('home.no_content')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]} style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Settings')} onPress={() => navigation.navigate('Settings')}
> >
<MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} /> <MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} />
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text> <Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>{t('home.add_catalogs')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}

View file

@ -14,9 +14,11 @@ import {
Dimensions Dimensions
} from 'react-native'; } from 'react-native';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { LinearGradient } from 'expo-linear-gradient';
import { useTranslation } from 'react-i18next';
import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons, Feather } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
@ -107,6 +109,7 @@ const SectionHeader: React.FC<{ title: string; isDarkMode: boolean; colors: any
); );
const HomeScreenSettings: React.FC = () => { const HomeScreenSettings: React.FC = () => {
const { t } = useTranslation();
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme(); const systemColorScheme = useColorScheme();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
@ -247,11 +250,11 @@ const HomeScreenSettings: React.FC = () => {
// Format selected catalogs text // Format selected catalogs text
const getSelectedCatalogsText = useCallback(() => { const getSelectedCatalogsText = useCallback(() => {
if (!settings.selectedHeroCatalogs || settings.selectedHeroCatalogs.length === 0) { if (!settings.selectedHeroCatalogs || settings.selectedHeroCatalogs.length === 0) {
return "All catalogs"; return t("home_screen.all_catalogs");
} else { } else {
return `${settings.selectedHeroCatalogs.length} selected`; return `${settings.selectedHeroCatalogs.length} ${t("home_screen.selected")}`;
} }
}, [settings.selectedHeroCatalogs]); }, [settings.selectedHeroCatalogs, t]);
const ChevronRight = () => ( const ChevronRight = () => (
<MaterialIcons <MaterialIcons
@ -268,14 +271,10 @@ const HomeScreenSettings: React.FC = () => {
]}> ]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} /> <StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.backButton}> <TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<MaterialIcons <Feather name="arrow-left" size={24} color={currentTheme.colors.text} />
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
/>
<Text style={[styles.backText, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}> <Text style={[styles.backText, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Settings {t('settings.title')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -285,7 +284,7 @@ const HomeScreenSettings: React.FC = () => {
</View> </View>
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}> <Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Home Screen Settings {t('home_screen.title')}
</Text> </Text>
{/* Saved indicator */} {/* Saved indicator */}
@ -300,7 +299,7 @@ const HomeScreenSettings: React.FC = () => {
pointerEvents="none" pointerEvents="none"
> >
<MaterialIcons name="check-circle" size={20} color="#FFFFFF" /> <MaterialIcons name="check-circle" size={20} color="#FFFFFF" />
<Text style={styles.savedIndicatorText}>Changes Applied</Text> <Text style={styles.savedIndicatorText}>{t('home_screen.changes_applied')}</Text>
</Animated.View> </Animated.View>
<ScrollView <ScrollView
@ -308,11 +307,11 @@ const HomeScreenSettings: React.FC = () => {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
> >
<SectionHeader title="DISPLAY OPTIONS" isDarkMode={isDarkMode} colors={colors} /> <SectionHeader title={t("home_screen.display_options")} isDarkMode={isDarkMode} colors={colors} />
<SettingsCard isDarkMode={isDarkMode} colors={colors}> <SettingsCard isDarkMode={isDarkMode} colors={colors}>
<SettingItem <SettingItem
title="Show Hero Section" title={t("home_screen.show_hero")}
description="Featured content at the top" description={t("home_screen.show_hero_desc")}
icon="movie-filter" icon="movie-filter"
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
colors={colors} colors={colors}
@ -324,8 +323,8 @@ const HomeScreenSettings: React.FC = () => {
)} )}
/> />
<SettingItem <SettingItem
title="Show This Week Section" title={t("home_screen.show_this_week")}
description="New episodes from current week" description={t("home_screen.show_this_week_desc")}
icon="date-range" icon="date-range"
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
colors={colors} colors={colors}
@ -338,7 +337,7 @@ const HomeScreenSettings: React.FC = () => {
/> />
{settings.showHeroSection && ( {settings.showHeroSection && (
<SettingItem <SettingItem
title="Select Catalogs" title={t("home_screen.select_catalogs")}
description={getSelectedCatalogsText()} description={getSelectedCatalogsText()}
icon="list" icon="list"
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
@ -354,29 +353,29 @@ const HomeScreenSettings: React.FC = () => {
<> <>
{!isTabletDevice && ( {!isTabletDevice && (
<View style={styles.segmentCard}> <View style={styles.segmentCard}>
<Text style={[styles.segmentTitle, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>Hero Layout</Text> <Text style={[styles.segmentTitle, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>{t('home_screen.hero_layout')}</Text>
<SegmentedControl <SegmentedControl
options={[ options={[
{ label: 'Legacy', value: 'legacy' }, { label: t('home_screen.layout_legacy'), value: 'legacy' },
{ label: 'Carousel', value: 'carousel' }, { label: t('home_screen.layout_carousel'), value: 'carousel' },
{ label: 'Apple TV', value: 'appletv' } { label: t('home_screen.layout_appletv'), value: 'appletv' }
]} ]}
value={settings.heroStyle} value={settings.heroStyle}
onChange={(val) => handleUpdateSetting('heroStyle', val as any)} onChange={(val) => handleUpdateSetting('heroStyle', val as any)}
/> />
<Text style={[styles.segmentHint, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>Full-width banner, swipeable cards, or Apple TV style</Text> <Text style={[styles.segmentHint, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>{t('home_screen.layout_desc')}</Text>
</View> </View>
)} )}
<View style={styles.segmentCard}> <View style={styles.segmentCard}>
<Text style={[styles.segmentTitle, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>Featured Source</Text> <Text style={[styles.segmentTitle, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>{t('home_screen.featured_source')}</Text>
<Text style={[styles.segmentHint, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>Using Catalogs</Text> <Text style={[styles.segmentHint, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>{t('home_screen.using_catalogs')}</Text>
<TouchableOpacity <TouchableOpacity
onPress={() => navigation.navigate('HeroCatalogs')} onPress={() => navigation.navigate('HeroCatalogs')}
style={[styles.manageLink, { backgroundColor: isDarkMode ? colors.elevation1 : 'rgba(0,0,0,0.04)' }]} style={[styles.manageLink, { backgroundColor: isDarkMode ? colors.elevation1 : 'rgba(0,0,0,0.04)' }]}
activeOpacity={0.8} activeOpacity={0.8}
> >
<Text style={{ color: isDarkMode ? colors.highEmphasis : colors.textDark, fontWeight: '600' }}>Manage selected catalogs</Text> <Text style={{ color: isDarkMode ? colors.highEmphasis : colors.textDark, fontWeight: '600' }}>{t('home_screen.manage_selected_catalogs')}</Text>
<MaterialIcons name="chevron-right" size={20} color={isDarkMode ? colors.mediumEmphasis : colors.textMutedDark} /> <MaterialIcons name="chevron-right" size={20} color={isDarkMode ? colors.mediumEmphasis : colors.textMutedDark} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -384,8 +383,8 @@ const HomeScreenSettings: React.FC = () => {
{settings.heroStyle === 'carousel' && ( {settings.heroStyle === 'carousel' && (
<SettingsCard isDarkMode={isDarkMode} colors={colors}> <SettingsCard isDarkMode={isDarkMode} colors={colors}>
<SettingItem <SettingItem
title="Dynamic Hero Background" title={t("home_screen.dynamic_bg")}
description="Blurred banner behind carousel" description={t("home_screen.dynamic_bg_desc")}
icon="wallpaper" icon="wallpaper"
isDarkMode={isDarkMode} isDarkMode={isDarkMode}
colors={colors} colors={colors}
@ -396,44 +395,44 @@ const HomeScreenSettings: React.FC = () => {
/> />
)} )}
/> />
<Text style={[styles.settingInlineNote, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>May impact performance on low-end devices.</Text> <Text style={[styles.settingInlineNote, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>{t('home_screen.performance_note')}</Text>
</SettingsCard> </SettingsCard>
)} )}
</> </>
)} )}
<SettingsCard isDarkMode={isDarkMode} colors={colors}> <SettingsCard isDarkMode={isDarkMode} colors={colors}>
<Text style={[styles.cardHeader, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>Posters</Text> <Text style={[styles.cardHeader, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>{t('home_screen.posters')}</Text>
<View style={styles.settingsRowInline}> <View style={styles.settingsRowInline}>
<Text style={[styles.rowLabel, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>Show Titles</Text> <Text style={[styles.rowLabel, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>{t('home_screen.show_titles')}</Text>
<CustomSwitch <CustomSwitch
value={settings.showPosterTitles} value={settings.showPosterTitles}
onValueChange={(value) => handleUpdateSetting('showPosterTitles', value)} onValueChange={(value) => handleUpdateSetting('showPosterTitles', value)}
/> />
</View> </View>
<View style={styles.settingsRow}> <View style={styles.settingsRow}>
<Text style={[styles.rowLabel, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>Poster Size</Text> <Text style={[styles.rowLabel, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>{t('home_screen.poster_size')}</Text>
<SegmentedControl <SegmentedControl
options={[{ label: 'Small', value: 'small' }, { label: 'Medium', value: 'medium' }, { label: 'Large', value: 'large' }]} options={[{ label: t('home_screen.size_small'), value: 'small' }, { label: t('home_screen.size_medium'), value: 'medium' }, { label: t('home_screen.size_large'), value: 'large' }]}
value={settings.posterSize} value={settings.posterSize}
onChange={(val) => handleUpdateSetting('posterSize', val as any)} onChange={(val) => handleUpdateSetting('posterSize', val as any)}
/> />
</View> </View>
<View style={styles.settingsRow}> <View style={styles.settingsRow}>
<Text style={[styles.rowLabel, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>Poster Corners</Text> <Text style={[styles.rowLabel, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>{t('home_screen.poster_corners')}</Text>
<SegmentedControl <SegmentedControl
options={[{ label: 'Square', value: '0' }, { label: 'Rounded', value: '12' }, { label: 'Pill', value: '20' }]} options={[{ label: t('home_screen.corners_square'), value: '0' }, { label: t('home_screen.corners_rounded'), value: '12' }, { label: t('home_screen.corners_pill'), value: '20' }]}
value={String(settings.posterBorderRadius)} value={String(settings.posterBorderRadius)}
onChange={(val) => handleUpdateSetting('posterBorderRadius', Number(val) as any)} onChange={(val) => handleUpdateSetting('posterBorderRadius', Number(val) as any)}
/> />
</View> </View>
</SettingsCard> </SettingsCard>
<SectionHeader title="ABOUT THESE SETTINGS" isDarkMode={isDarkMode} colors={colors} /> <SectionHeader title={t("home_screen.about_these_settings")} isDarkMode={isDarkMode} colors={colors} />
<View style={[styles.infoCard, { backgroundColor: isDarkMode ? colors.elevation1 : 'rgba(0,0,0,0.03)' }]}> <View style={[styles.infoCard, { backgroundColor: isDarkMode ? colors.elevation1 : 'rgba(0,0,0,0.03)' }]}>
<Text style={[styles.infoText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}> <Text style={[styles.infoText, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>
These settings control how content is displayed on your Home screen. Changes are applied immediately without requiring an app restart. {t('home_screen.about_desc')}
</Text> </Text>
</View> </View>
</ScrollView> </ScrollView>

View file

@ -38,6 +38,7 @@ import TraktIcon from '../../assets/rating-icons/trakt.svg';
import { traktService, TraktService, TraktImages } from '../services/traktService'; import { traktService, TraktService, TraktImages } from '../services/traktService';
import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner'; import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { useTranslation } from 'react-i18next';
import { useScrollToTop } from '../contexts/ScrollToTopContext'; import { useScrollToTop } from '../contexts/ScrollToTopContext';
interface LibraryItem extends StreamingContent { interface LibraryItem extends StreamingContent {
@ -211,6 +212,7 @@ const SkeletonLoader = () => {
}; };
const LibraryScreen = () => { const LibraryScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
const { width, height } = useWindowDimensions(); const { width, height } = useWindowDimensions();
@ -361,31 +363,31 @@ const LibraryScreen = () => {
const folders: TraktFolder[] = [ const folders: TraktFolder[] = [
{ {
id: 'watched', id: 'watched',
name: 'Watched', name: t('library.watched'),
icon: 'visibility', icon: 'visibility',
itemCount: (watchedMovies?.length || 0) + (watchedShows?.length || 0), itemCount: (watchedMovies?.length || 0) + (watchedShows?.length || 0),
}, },
{ {
id: 'continue-watching', id: 'continue-watching',
name: 'Continue', name: t('library.continue'),
icon: 'play-circle-outline', icon: 'play-circle-outline',
itemCount: continueWatching?.length || 0, itemCount: continueWatching?.length || 0,
}, },
{ {
id: 'watchlist', id: 'watchlist',
name: 'Watchlist', name: t('library.watchlist'),
icon: 'bookmark', icon: 'bookmark',
itemCount: (watchlistMovies?.length || 0) + (watchlistShows?.length || 0), itemCount: (watchlistMovies?.length || 0) + (watchlistShows?.length || 0),
}, },
{ {
id: 'collection', id: 'collection',
name: 'Collection', name: t('library.collection'),
icon: 'library-add', icon: 'library-add',
itemCount: (collectionMovies?.length || 0) + (collectionShows?.length || 0), itemCount: (collectionMovies?.length || 0) + (collectionShows?.length || 0),
}, },
{ {
id: 'ratings', id: 'ratings',
name: 'Rated', name: t('library.rated'),
icon: 'star', icon: 'star',
itemCount: ratedContent?.length || 0, itemCount: ratedContent?.length || 0,
} }
@ -457,7 +459,7 @@ const LibraryScreen = () => {
{folder.name} {folder.name}
</Text> </Text>
<Text style={styles.folderCount}> <Text style={styles.folderCount}>
{folder.itemCount} items {folder.itemCount} {t('library.items')}
</Text> </Text>
</View> </View>
</View> </View>
@ -487,14 +489,14 @@ const LibraryScreen = () => {
</Text> </Text>
{traktAuthenticated && traktFolders.length > 0 && ( {traktAuthenticated && traktFolders.length > 0 && (
<Text style={styles.folderCount}> <Text style={styles.folderCount}>
{traktFolders.length} items {traktFolders.length} {t('library.items')}
</Text> </Text>
)} )}
</View> </View>
</View> </View>
{settings.showPosterTitles && ( {settings.showPosterTitles && (
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
Trakt collections {t('library.trakt_collections')}
</Text> </Text>
)} )}
</View> </View>
@ -720,9 +722,9 @@ const LibraryScreen = () => {
return ( return (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<TraktIcon width={80} height={80} style={{ opacity: 0.7, marginBottom: 16 }} /> <TraktIcon width={80} height={80} style={{ opacity: 0.7, marginBottom: 16 }} />
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>No Trakt collections</Text> <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>{t('library.no_trakt')}</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Your Trakt collections will appear here once you start using Trakt {t('library.no_trakt_desc')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.exploreButton, { style={[styles.exploreButton, {
@ -734,7 +736,7 @@ const LibraryScreen = () => {
}} }}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Load Collections</Text> <Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>{t('library.load_collections')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
@ -758,13 +760,13 @@ const LibraryScreen = () => {
const folderItems = getTraktFolderItems(selectedTraktFolder); const folderItems = getTraktFolderItems(selectedTraktFolder);
if (folderItems.length === 0) { if (folderItems.length === 0) {
const folderName = traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'; const folderName = traktFolders.find(f => f.id === selectedTraktFolder)?.name || t('library.collection');
return ( return (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<TraktIcon width={80} height={80} style={{ opacity: 0.7, marginBottom: 16 }} /> <TraktIcon width={80} height={80} style={{ opacity: 0.7, marginBottom: 16 }} />
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>No content in {folderName}</Text> <Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>{t('library.empty_folder', { folder: folderName })}</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}> <Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
This collection is empty {t('library.empty_folder_desc')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[styles.exploreButton, { style={[styles.exploreButton, {
@ -854,8 +856,8 @@ const LibraryScreen = () => {
} }
if (filteredItems.length === 0) { if (filteredItems.length === 0) {
const emptyTitle = filter === 'movies' ? 'No movies yet' : filter === 'series' ? 'No TV shows yet' : 'No content yet'; const emptyTitle = filter === 'movies' ? t('library.no_movies') : filter === 'series' ? t('library.no_series') : t('library.no_content');
const emptySubtitle = 'Add some content to your library to see it here'; const emptySubtitle = t('library.add_content_desc');
return ( return (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<MaterialIcons <MaterialIcons
@ -877,7 +879,7 @@ const LibraryScreen = () => {
onPress={() => navigation.navigate('Search')} onPress={() => navigation.navigate('Search')}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Find something to watch</Text> <Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>{t('library.find_something')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
@ -908,9 +910,9 @@ const LibraryScreen = () => {
<ScreenHeader <ScreenHeader
title={showTraktContent title={showTraktContent
? (selectedTraktFolder ? (selectedTraktFolder
? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection' ? traktFolders.find(f => f.id === selectedTraktFolder)?.name || t('library.collection')
: 'Trakt Collection') : t('library.trakt_collection'))
: 'Library' : t('library.title')
} }
showBackButton={showTraktContent} showBackButton={showTraktContent}
onBackPress={showTraktContent ? () => { onBackPress={showTraktContent ? () => {
@ -930,8 +932,8 @@ const LibraryScreen = () => {
{!showTraktContent && ( {!showTraktContent && (
<View style={styles.filtersContainer}> <View style={styles.filtersContainer}>
{renderFilter('trakt', 'Trakt', 'pan-tool')} {renderFilter('trakt', 'Trakt', 'pan-tool')}
{renderFilter('movies', 'Movies', 'movie')} {renderFilter('movies', t('search.movies'), 'movie')}
{renderFilter('series', 'TV Shows', 'live-tv')} {renderFilter('series', t('search.tv_shows'), 'live-tv')}
</View> </View>
)} )}
@ -951,11 +953,11 @@ const LibraryScreen = () => {
case 'library': { case 'library': {
try { try {
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
showInfo('Removed from Library', 'Item removed from your library'); showInfo(t('library.removed_from_library'), t('library.item_removed'));
setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type))); setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type)));
setMenuVisible(false); setMenuVisible(false);
} catch (error) { } catch (error) {
showError('Failed to update Library', 'Unable to remove item from library'); showError(t('library.failed_update_library'), t('library.unable_remove'));
} }
break; break;
} }
@ -964,14 +966,14 @@ const LibraryScreen = () => {
const key = `watched:${selectedItem.type}:${selectedItem.id}`; const key = `watched:${selectedItem.type}:${selectedItem.id}`;
const newWatched = !selectedItem.watched; const newWatched = !selectedItem.watched;
await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); await mmkvStorage.setItem(key, newWatched ? 'true' : 'false');
showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched'); showInfo(newWatched ? t('library.marked_watched') : t('library.marked_unwatched'), newWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched'));
setLibraryItems(prev => prev.map(item => setLibraryItems(prev => prev.map(item =>
item.id === selectedItem.id && item.type === selectedItem.type item.id === selectedItem.id && item.type === selectedItem.type
? { ...item, watched: newWatched } ? { ...item, watched: newWatched }
: item : item
)); ));
} catch (error) { } catch (error) {
showError('Failed to update watched status', 'Unable to update watched status'); showError(t('library.failed_update_watched'), t('library.unable_update_watched'));
} }
break; break;
} }

View file

@ -14,14 +14,18 @@ import {
Keyboard, Keyboard,
Clipboard, Clipboard,
Switch, Switch,
useColorScheme,
} from 'react-native'; } from 'react-native';
import CustomAlert from '../components/CustomAlert'; import { LinearGradient } from 'expo-linear-gradient';
import { useNavigation } from '@react-navigation/native'; import { useTranslation } from 'react-i18next';
import { useNavigation, useFocusEffect, NavigationProp } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import Feather from 'react-native-vector-icons/Feather'; // Added Feather icon import
import { mmkvStorage } from '../services/mmkvStorage'; import { mmkvStorage } from '../services/mmkvStorage';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { RATING_PROVIDERS } from '../components/metadata/RatingsSection'; import { RATING_PROVIDERS } from '../components/metadata/RatingsSection';
import CustomAlert from '../components/CustomAlert'; // Moved CustomAlert import here
export const MDBLIST_API_KEY_STORAGE_KEY = 'mdblist_api_key'; export const MDBLIST_API_KEY_STORAGE_KEY = 'mdblist_api_key';
export const RATING_PROVIDERS_STORAGE_KEY = 'rating_providers_config'; export const RATING_PROVIDERS_STORAGE_KEY = 'rating_providers_config';
@ -47,7 +51,7 @@ export const getMDBListAPIKey = async (): Promise<string | null> => {
logger.log('[MDBList] MDBList is disabled, not retrieving API key'); logger.log('[MDBList] MDBList is disabled, not retrieving API key');
return null; return null;
} }
return await mmkvStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY); return await mmkvStorage.getItem(MDBLIST_API_KEY_STORAGE_KEY);
} catch (error) { } catch (error) {
logger.error('[MDBList] Error retrieving API key:', error); logger.error('[MDBList] Error retrieving API key:', error);
@ -64,9 +68,9 @@ const createStyles = (colors: any) => StyleSheet.create({
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
paddingBottom: 10,
}, },
backButton: { backButton: {
flexDirection: 'row', flexDirection: 'row',
@ -87,6 +91,11 @@ const createStyles = (colors: any) => StyleSheet.create({
paddingBottom: 16, paddingBottom: 16,
paddingTop: 8, paddingTop: 8,
}, },
title: {
fontSize: 20,
fontWeight: '600',
marginLeft: 10,
},
content: { content: {
flex: 1, flex: 1,
}, },
@ -134,12 +143,20 @@ const createStyles = (colors: any) => StyleSheet.create({
statusTextContainer: { statusTextContainer: {
flex: 1, flex: 1,
}, },
statusContent: {
flex: 1,
},
statusTitle: { statusTitle: {
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
color: colors.white, color: colors.white,
marginBottom: 2, marginBottom: 2,
}, },
statusSubtitle: {
fontSize: 13,
color: colors.mediumGray,
lineHeight: 18,
},
statusDescription: { statusDescription: {
fontSize: 13, fontSize: 13,
color: colors.mediumGray, color: colors.mediumGray,
@ -151,6 +168,11 @@ const createStyles = (colors: any) => StyleSheet.create({
color: colors.lightGray, color: colors.lightGray,
marginBottom: 10, marginBottom: 10,
}, },
sectionSubtitle: {
fontSize: 13,
color: colors.mediumGray,
marginBottom: 12,
},
inputWrapper: { inputWrapper: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -159,6 +181,17 @@ const createStyles = (colors: any) => StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: colors.border, borderColor: colors.border,
}, },
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 8,
borderWidth: 1,
paddingHorizontal: 10,
marginBottom: 10,
},
inputIcon: {
marginRight: 10,
},
input: { input: {
flex: 1, flex: 1,
paddingVertical: 10, paddingVertical: 10,
@ -197,7 +230,7 @@ const createStyles = (colors: any) => StyleSheet.create({
}, },
buttonContainer: { buttonContainer: {
marginTop: 12, marginTop: 12,
gap: 10, gap: 10,
}, },
buttonIcon: { buttonIcon: {
marginRight: 6, marginRight: 6,
@ -212,7 +245,7 @@ const createStyles = (colors: any) => StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
}, },
saveButtonDisabled: { saveButtonDisabled: {
backgroundColor: colors.elevation2, backgroundColor: colors.elevation2,
opacity: 0.8, opacity: 0.8,
}, },
saveButtonText: { saveButtonText: {
@ -242,12 +275,15 @@ const createStyles = (colors: any) => StyleSheet.create({
clearButtonTextDisabled: { clearButtonTextDisabled: {
color: colors.darkGray, color: colors.darkGray,
}, },
buttonDisabled: {
opacity: 0.5,
},
infoHeader: { infoHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginBottom: 10, marginBottom: 10,
}, },
infoHeaderText: { infoTitle: {
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: colors.white, color: colors.white,
@ -255,7 +291,38 @@ const createStyles = (colors: any) => StyleSheet.create({
}, },
infoSteps: { infoSteps: {
marginBottom: 12, marginBottom: 12,
gap: 6, gap: 6,
},
stepsContainer: {
marginBottom: 15,
},
stepRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 10,
},
stepNumber: {
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
marginRight: 10,
},
stepNumberText: {
color: colors.white,
fontSize: 12,
fontWeight: 'bold',
},
stepText: {
flex: 1,
fontSize: 13,
color: colors.mediumGray,
lineHeight: 18,
},
linkText: {
fontWeight: '600',
}, },
infoStep: { infoStep: {
flexDirection: 'row', flexDirection: 'row',
@ -355,12 +422,19 @@ const createStyles = (colors: any) => StyleSheet.create({
}, },
}); });
const MDBListSettingsScreen = () => { interface RootStackParamList {
const navigation = useNavigation(); Settings: undefined;
// Add other routes if necessary
}
const MDBListSettingsScreen: React.FC = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const isDarkMode = useColorScheme() === 'dark';
const colors = currentTheme.colors; const colors = currentTheme.colors;
const styles = createStyles(colors); const styles = createStyles(colors);
// Custom alert state // Custom alert state
const [alertVisible, setAlertVisible] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState(''); const [alertTitle, setAlertTitle] = useState('');
@ -471,38 +545,48 @@ const MDBListSettingsScreen = () => {
const saveApiKey = async () => { const saveApiKey = async () => {
logger.log('[MDBListSettingsScreen] Starting API key save'); logger.log('[MDBListSettingsScreen] Starting API key save');
Keyboard.dismiss(); Keyboard.dismiss();
try { try {
const trimmedKey = apiKey.trim(); const trimmedKey = apiKey.trim();
if (!trimmedKey) { if (!trimmedKey) {
logger.warn('[MDBListSettingsScreen] Empty API key provided'); logger.warn('[MDBListSettingsScreen] Empty API key provided');
setTestResult({ success: false, message: 'API Key cannot be empty.' }); setAlertTitle(t('common.error'));
setAlertMessage(t('mdblist.api_key_empty_error'));
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true);
return; return;
} }
logger.log('[MDBListSettingsScreen] Saving API key'); logger.log('[MDBListSettingsScreen] Saving API key');
await mmkvStorage.setItem(MDBLIST_API_KEY_STORAGE_KEY, trimmedKey); await mmkvStorage.setItem(MDBLIST_API_KEY_STORAGE_KEY, trimmedKey);
setIsKeySet(true); setIsKeySet(true);
setTestResult({ success: true, message: 'API key saved successfully.' }); setAlertTitle(t('common.success'));
setAlertMessage(t('mdblist.success_saved'));
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true);
logger.log('[MDBListSettingsScreen] API key saved successfully'); logger.log('[MDBListSettingsScreen] API key saved successfully');
} catch (error) { } catch (error) {
logger.error('[MDBListSettingsScreen] Error saving API key:', error); logger.error('[MDBListSettingsScreen] Error saving API key:', error);
setTestResult({ setAlertTitle(t('common.error'));
success: false, setAlertMessage(t('mdblist.error_save'));
message: 'An error occurred while saving. Please try again.' setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
}); setAlertVisible(true);
} }
}; };
const clearApiKey = async () => { const handleClear = async () => {
logger.log('[MDBListSettingsScreen] Clear API key requested'); logger.log('[MDBListSettingsScreen] Clear API key requested');
setAlertTitle('Clear API Key'); setAlertTitle(t('mdblist.alert_clear_title'));
setAlertMessage('Are you sure you want to remove the saved API key?'); setAlertMessage(t('mdblist.alert_clear_msg'));
setAlertActions([ setAlertActions([
{ label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } },
{ {
label: 'Clear', label: t('common.cancel'),
onPress: () => setAlertVisible(false),
style: { color: currentTheme.colors.mediumGray }
},
{
label: t('mdblist.clear'),
onPress: async () => { onPress: async () => {
logger.log('[MDBListSettingsScreen] Proceeding with API key clear'); logger.log('[MDBListSettingsScreen] Proceeding with API key clear');
try { try {
@ -510,12 +594,16 @@ const MDBListSettingsScreen = () => {
setApiKey(''); setApiKey('');
setIsKeySet(false); setIsKeySet(false);
setTestResult(null); setTestResult(null);
setAlertTitle(t('common.success'));
setAlertMessage(t('mdblist.success_cleared'));
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true);
logger.log('[MDBListSettingsScreen] API key cleared successfully'); logger.log('[MDBListSettingsScreen] API key cleared successfully');
} catch (error) { } catch (error) {
logger.error('[MDBListSettingsScreen] Failed to clear API key:', error); logger.error('[MDBListSettingsScreen] Failed to clear API key:', error);
setAlertTitle('Error'); setAlertTitle(t('common.error'));
setAlertMessage('Failed to clear API key'); setAlertMessage(t('mdblist.error_clear'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} }
}, },
@ -554,7 +642,7 @@ const MDBListSettingsScreen = () => {
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading Settings...</Text> <Text style={styles.loadingText}>{t('common.loading_settings')}</Text>
</View> </View>
</SafeAreaView> </SafeAreaView>
); );
@ -562,44 +650,38 @@ const MDBListSettingsScreen = () => {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity <TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
style={styles.backButton} <Feather name="arrow-left" size={24} color={currentTheme.colors.text} />
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.primary} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>
{t('mdblist.title')}
</Text>
</View> </View>
<Text style={styles.headerTitle}>Rating Sources</Text>
<ScrollView <ScrollView
style={styles.content} style={styles.content}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<View style={styles.statusCard}> <View style={styles.statusCard}>
<MaterialIcons <MaterialIcons
name={isKeySet && isMdbListEnabled ? "check-circle" : "error-outline"} name={isKeySet && isMdbListEnabled ? "check-circle" : "error-outline"}
size={28} size={28}
color={isKeySet && isMdbListEnabled ? colors.success : colors.warning} color={isKeySet && isMdbListEnabled ? colors.success : colors.warning}
style={styles.statusIcon} style={styles.statusIcon}
/> />
<View style={styles.statusTextContainer}> <View style={styles.statusContent}>
<Text style={styles.statusTitle}> <Text style={[styles.statusTitle, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
{!isMdbListEnabled
? "MDBList Disabled"
: isKeySet
? "API Key Active"
: "API Key Required"}
</Text>
<Text style={styles.statusDescription}>
{!isMdbListEnabled {!isMdbListEnabled
? "MDBList functionality is currently disabled." ? t('mdblist.status_disabled')
: isKeySet : (isKeySet ? t('mdblist.status_active') : t('mdblist.status_required'))}
? "Ratings from MDBList are enabled." </Text>
: "Add your key below to enable ratings."} <Text style={[styles.statusSubtitle, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
{!isMdbListEnabled
? t('mdblist.status_disabled_desc')
: (isKeySet ? t('mdblist.status_active_desc') : t('mdblist.status_required_desc'))}
</Text> </Text>
</View> </View>
</View> </View>
@ -607,10 +689,8 @@ const MDBListSettingsScreen = () => {
<View style={styles.card}> <View style={styles.card}>
<View style={styles.masterToggleContainer}> <View style={styles.masterToggleContainer}>
<View style={styles.masterToggleInfo}> <View style={styles.masterToggleInfo}>
<Text style={styles.masterToggleTitle}>Enable MDBList</Text> <Text style={styles.masterToggleTitle}>{t('mdblist.enable_toggle')}</Text>
<Text style={styles.masterToggleDescription}> <Text style={styles.masterToggleDescription}>{t('mdblist.enable_toggle_desc')}</Text>
Turn on/off all MDBList functionality
</Text>
</View> </View>
<Switch <Switch
value={isMdbListEnabled} value={isMdbListEnabled}
@ -622,21 +702,29 @@ const MDBListSettingsScreen = () => {
</View> </View>
<View style={[styles.card, !isMdbListEnabled && styles.disabledCard]}> <View style={[styles.card, !isMdbListEnabled && styles.disabledCard]}>
<Text style={styles.sectionTitle}>API Key</Text> <Text style={[styles.sectionTitle, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
<View style={[styles.inputWrapper, !isMdbListEnabled && styles.disabledInput]}> {t('mdblist.api_section')}
</Text>
<View style={[styles.inputContainer, {
backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : '#F5F5F5',
borderColor: isDarkMode ? 'transparent' : '#E0E0E0'
}]}>
<MaterialIcons name="vpn-key" size={20} color={currentTheme.colors.mediumEmphasis} style={styles.inputIcon} />
<TextInput <TextInput
ref={apiKeyInputRef} ref={apiKeyInputRef}
style={[ style={[
styles.input, styles.input,
isInputFocused && styles.inputFocused, isInputFocused && styles.inputFocused,
!isMdbListEnabled && styles.disabledText !isMdbListEnabled && styles.disabledText,
{ color: currentTheme.colors.text }
]} ]}
value={apiKey} value={apiKey}
onChangeText={(text) => { onChangeText={(text) => {
setApiKey(text); setApiKey(text);
if (testResult) setTestResult(null); if (testResult) setTestResult(null);
}} }}
placeholder="Paste your MDBList API key" placeholder={t('mdblist.placeholder')}
placeholderTextColor={!isMdbListEnabled ? colors.darkGray : colors.mediumGray} placeholderTextColor={!isMdbListEnabled ? colors.darkGray : colors.mediumGray}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@ -644,66 +732,67 @@ const MDBListSettingsScreen = () => {
onFocus={() => setIsInputFocused(true)} onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)} onBlur={() => setIsInputFocused(false)}
editable={isMdbListEnabled} editable={isMdbListEnabled}
secureTextEntry
/> />
<TouchableOpacity <TouchableOpacity
style={styles.pasteButton} style={styles.pasteButton}
onPress={pasteFromClipboard} onPress={pasteFromClipboard}
disabled={!isMdbListEnabled} disabled={!isMdbListEnabled}
> >
<MaterialIcons <MaterialIcons
name="content-paste" name="content-paste"
size={20} size={20}
color={!isMdbListEnabled ? colors.darkGray : colors.primary} color={!isMdbListEnabled ? colors.darkGray : colors.primary}
/> />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{testResult && ( {testResult && (
<View style={[ <View style={[
styles.testResultContainer, styles.testResultContainer,
testResult.success ? styles.testResultSuccess : styles.testResultError testResult.success ? styles.testResultSuccess : styles.testResultError
]}> ]}>
<MaterialIcons <MaterialIcons
name={testResult.success ? "check" : "warning"} name={testResult.success ? "check" : "warning"}
size={18} size={18}
color={testResult.success ? colors.success : colors.error} color={testResult.success ? colors.success : colors.error}
/> />
<Text style={styles.testResultText}> <Text style={styles.testResultText}>
{testResult.message} {testResult.message}
</Text> </Text>
</View> </View>
)} )}
<View style={styles.buttonContainer}> <View style={styles.buttonContainer}>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.saveButton, styles.saveButton,
(!apiKey.trim() || !isMdbListEnabled) && styles.saveButtonDisabled (!apiKey.trim() || !isMdbListEnabled) && styles.saveButtonDisabled
]} ]}
onPress={saveApiKey} onPress={saveApiKey}
disabled={!apiKey.trim() || !isMdbListEnabled} disabled={!apiKey.trim() || !isMdbListEnabled}
> >
<MaterialIcons name="save" size={18} color={colors.white} style={styles.buttonIcon} /> <MaterialIcons name="save" size={18} color={colors.white} style={styles.buttonIcon} />
<Text style={styles.saveButtonText}>Save</Text> <Text style={styles.saveButtonText}>{t('mdblist.save')}</Text>
</TouchableOpacity> </TouchableOpacity>
{isKeySet && ( {isKeySet && (
<TouchableOpacity <TouchableOpacity
style={[styles.clearButton, !isMdbListEnabled && styles.clearButtonDisabled]} style={[styles.clearButton, !isMdbListEnabled && styles.clearButtonDisabled]}
onPress={clearApiKey} onPress={handleClear}
disabled={!isMdbListEnabled} disabled={!isMdbListEnabled}
> >
<MaterialIcons <MaterialIcons
name="delete-outline" name="delete-outline"
size={18} size={18}
color={!isMdbListEnabled ? colors.darkGray : colors.error} color={!isMdbListEnabled ? colors.darkGray : colors.error}
style={styles.buttonIcon} style={styles.buttonIcon}
/> />
<Text style={[ <Text style={[
styles.clearButtonText, styles.clearButtonText,
!isMdbListEnabled && styles.clearButtonTextDisabled !isMdbListEnabled && styles.clearButtonTextDisabled
]}> ]}>
Clear Key {t('mdblist.clear')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@ -711,9 +800,11 @@ const MDBListSettingsScreen = () => {
</View> </View>
<View style={[styles.card, !isMdbListEnabled && styles.disabledCard]}> <View style={[styles.card, !isMdbListEnabled && styles.disabledCard]}>
<Text style={styles.sectionTitle}>Rating Providers</Text> <Text style={[styles.sectionTitle, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
<Text style={styles.sectionDescription}> {t('mdblist.rating_providers')}
Choose which ratings to display in the app </Text>
<Text style={[styles.sectionSubtitle, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
{t('mdblist.rating_providers_desc')}
</Text> </Text>
{Object.entries(RATING_PROVIDERS).map(([id, provider]) => ( {Object.entries(RATING_PROVIDERS).map(([id, provider]) => (
<View key={id} style={styles.providerItem}> <View key={id} style={styles.providerItem}>
@ -738,68 +829,37 @@ const MDBListSettingsScreen = () => {
<View style={[styles.infoCard, !isMdbListEnabled && styles.disabledCard]}> <View style={[styles.infoCard, !isMdbListEnabled && styles.disabledCard]}>
<View style={styles.infoHeader}> <View style={styles.infoHeader}>
<MaterialIcons <Feather name="info" size={20} color={currentTheme.colors.primary} />
name="help-outline" <Text style={[styles.infoTitle, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
size={20} {t('mdblist.how_to')}
color={!isMdbListEnabled ? colors.darkGray : colors.primary}
/>
<Text style={[
styles.infoHeaderText,
!isMdbListEnabled && styles.disabledText
]}>
How to get an API key
</Text> </Text>
</View> </View>
<View style={styles.infoSteps}>
<View style={styles.infoStep}> <View style={styles.stepsContainer}>
<Text style={[ <View style={styles.stepRow}>
styles.infoStepNumber, <View style={[styles.stepNumber, { backgroundColor: currentTheme.colors.primary }]}>
!isMdbListEnabled && styles.disabledText <Text style={styles.stepNumberText}>1</Text>
]}> </View>
1. <Text style={[styles.stepText, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
{t('mdblist.step_1')} <Text style={[styles.linkText, { color: currentTheme.colors.primary }]} onPress={openMDBListWebsite}>{t('mdblist.step_1_link')}</Text>.
</Text> </Text>
<Text style={[ </View>
styles.infoStepText,
!isMdbListEnabled && styles.disabledText <View style={styles.stepRow}>
]}> <View style={[styles.stepNumber, { backgroundColor: currentTheme.colors.primary }]}>
Log in on the <Text style={[ <Text style={styles.stepNumberText}>2</Text>
styles.boldText, </View>
!isMdbListEnabled && styles.disabledBoldText <Text style={[styles.stepText, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
]}>MDBList website</Text>. {t('mdblist.step_2')} <Text style={{ fontWeight: 'bold', color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }}>{t('mdblist.step_2_settings')}</Text> {'>'} <Text style={{ fontWeight: 'bold', color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }}>{t('mdblist.step_2_api')}</Text> {t('mdblist.step_2_end')}
</Text> </Text>
</View> </View>
<View style={styles.infoStep}>
<Text style={[ <View style={styles.stepRow}>
styles.infoStepNumber, <View style={[styles.stepNumber, { backgroundColor: currentTheme.colors.primary }]}>
!isMdbListEnabled && styles.disabledText <Text style={styles.stepNumberText}>3</Text>
]}> </View>
2. <Text style={[styles.stepText, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
</Text> {t('mdblist.step_3')}
<Text style={[
styles.infoStepText,
!isMdbListEnabled && styles.disabledText
]}>
Go to <Text style={[
styles.boldText,
!isMdbListEnabled && styles.disabledBoldText
]}>Settings</Text> {'>'} <Text style={[
styles.boldText,
!isMdbListEnabled && styles.disabledBoldText
]}>API</Text> section.
</Text>
</View>
<View style={styles.infoStep}>
<Text style={[
styles.infoStepNumber,
!isMdbListEnabled && styles.disabledText
]}>
3.
</Text>
<Text style={[
styles.infoStepText,
!isMdbListEnabled && styles.disabledText
]}>
Generate a new key and copy it.
</Text> </Text>
</View> </View>
</View> </View>
@ -811,29 +871,29 @@ const MDBListSettingsScreen = () => {
onPress={openMDBListWebsite} onPress={openMDBListWebsite}
disabled={!isMdbListEnabled} disabled={!isMdbListEnabled}
> >
<MaterialIcons <MaterialIcons
name="open-in-new" name="open-in-new"
size={18} size={18}
color={!isMdbListEnabled ? colors.darkGray : colors.primary} color={!isMdbListEnabled ? currentTheme.colors.mediumEmphasis : currentTheme.colors.primary}
style={styles.buttonIcon} style={styles.buttonIcon}
/> />
<Text style={[ <Text style={[
styles.websiteButtonText, styles.websiteButtonText,
!isMdbListEnabled && styles.websiteButtonTextDisabled !isMdbListEnabled && styles.websiteButtonTextDisabled
]}> ]}>
Go to MDBList {t('mdblist.go_to_website')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
<CustomAlert <CustomAlert
visible={alertVisible} visible={alertVisible}
title={alertTitle} title={alertTitle}
message={alertMessage} message={alertMessage}
onClose={() => setAlertVisible(false)} onClose={() => setAlertVisible(false)}
actions={alertActions} actions={alertActions}
/> />
</SafeAreaView> </SafeAreaView>
); );
}; };

View file

@ -12,6 +12,7 @@ import {
Platform, Platform,
Alert, Alert,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native'; import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
@ -88,6 +89,7 @@ const MetadataScreen: React.FC = () => {
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>(); const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { id, type, episodeId, addonId } = route.params; const { id, type, episodeId, addonId } = route.params;
const { t } = useTranslation();
// Log route parameters for debugging // Log route parameters for debugging
React.useEffect(() => { React.useEffect(() => {
@ -726,15 +728,15 @@ const MetadataScreen: React.FC = () => {
const handleSpoilerPress = useCallback((comment: any) => { const handleSpoilerPress = useCallback((comment: any) => {
Alert.alert( Alert.alert(
'Spoiler Warning', t('metadata.spoiler_warning'),
'This comment contains spoilers. Are you sure you want to reveal it?', t('metadata.spoiler_warning_desc'),
[ [
{ {
text: 'Cancel', text: t('metadata.cancel'),
style: 'cancel', style: 'cancel',
}, },
{ {
text: 'Reveal Spoilers', text: t('metadata.reveal_spoilers'),
style: 'destructive', style: 'destructive',
onPress: () => { onPress: () => {
setRevealedSpoilers(prev => new Set([...prev, comment.id.toString()])); setRevealedSpoilers(prev => new Set([...prev, comment.id.toString()]));
@ -742,7 +744,7 @@ const MetadataScreen: React.FC = () => {
}, },
] ]
); );
}, []); }, [t]);
// Source switching removed // Source switching removed
@ -780,19 +782,19 @@ const MetadataScreen: React.FC = () => {
console.log('✅ Found status code:', code); console.log('✅ Found status code:', code);
switch (code) { switch (code) {
case 404: case 404:
return { code: '404', message: 'Content not found', userMessage: 'This content doesn\'t exist or may have been removed.' }; return { code: '404', message: t('metadata.content_not_found'), userMessage: t('metadata.content_not_found_desc') };
case 500: case 500:
return { code: '500', message: 'Server error', userMessage: 'The server is temporarily unavailable. Please try again later.' }; return { code: '500', message: t('metadata.server_error'), userMessage: t('metadata.server_error_desc') };
case 502: case 502:
return { code: '502', message: 'Bad gateway', userMessage: 'The server is experiencing issues. Please try again later.' }; return { code: '502', message: t('metadata.bad_gateway'), userMessage: t('metadata.bad_gateway_desc') };
case 503: case 503:
return { code: '503', message: 'Service unavailable', userMessage: 'The service is currently down for maintenance. Please try again later.' }; return { code: '503', message: t('metadata.service_unavailable'), userMessage: t('metadata.service_unavailable_desc') };
case 429: case 429:
return { code: '429', message: 'Too many requests', userMessage: 'You\'re making too many requests. Please wait a moment and try again.' }; return { code: '429', message: t('metadata.too_many_requests'), userMessage: t('metadata.too_many_requests_desc') };
case 408: case 408:
return { code: '408', message: 'Request timeout', userMessage: 'The request took too long. Please try again.' }; return { code: '408', message: t('metadata.request_timeout'), userMessage: t('metadata.request_timeout_desc') };
default: default:
return { code: code.toString(), message: `Error ${code}`, userMessage: 'Something went wrong. Please try again.' }; return { code: code.toString(), message: `Error ${code}`, userMessage: t('metadata.something_went_wrong') };
} }
} }
@ -801,7 +803,7 @@ const MetadataScreen: React.FC = () => {
error.includes('ERR_BAD_RESPONSE') || error.includes('ERR_BAD_RESPONSE') ||
error.includes('Request failed') || error.includes('Request failed') ||
error.includes('ERR_NETWORK')) { error.includes('ERR_NETWORK')) {
return { code: 'NETWORK', message: 'Network error', userMessage: 'Please check your internet connection and try again.' }; return { code: 'NETWORK', message: t('metadata.network_error'), userMessage: t('metadata.network_error_desc') };
} }
// Check for timeout errors // Check for timeout errors
@ -809,36 +811,36 @@ const MetadataScreen: React.FC = () => {
error.includes('timed out') || error.includes('timed out') ||
error.includes('ECONNABORTED') || error.includes('ECONNABORTED') ||
error.includes('ETIMEDOUT')) { error.includes('ETIMEDOUT')) {
return { code: 'TIMEOUT', message: 'Request timeout', userMessage: 'The request took too long. Please try again.' }; return { code: 'TIMEOUT', message: t('metadata.request_timeout'), userMessage: t('metadata.request_timeout_desc') };
} }
// Check for authentication errors // Check for authentication errors
if (error.includes('401') || error.includes('Unauthorized') || error.includes('authentication')) { if (error.includes('401') || error.includes('Unauthorized') || error.includes('authentication')) {
return { code: '401', message: 'Authentication error', userMessage: 'Please check your account settings and try again.' }; return { code: '401', message: t('metadata.auth_error'), userMessage: t('metadata.auth_error_desc') };
} }
// Check for permission errors // Check for permission errors
if (error.includes('403') || error.includes('Forbidden') || error.includes('permission')) { if (error.includes('403') || error.includes('Forbidden') || error.includes('permission')) {
return { code: '403', message: 'Access denied', userMessage: 'You don\'t have permission to access this content.' }; return { code: '403', message: t('metadata.access_denied'), userMessage: t('metadata.access_denied_desc') };
} }
// Check for "not found" errors - but only if no status code was found // Check for "not found" errors - but only if no status code was found
if (!statusCodeMatch && (error.includes('Content not found') || error.includes('not found'))) { if (!statusCodeMatch && (error.includes('Content not found') || error.includes('not found'))) {
return { code: '404', message: 'Content not found', userMessage: 'This content doesn\'t exist or may have been removed.' }; return { code: '404', message: t('metadata.content_not_found'), userMessage: t('metadata.content_not_found_desc') };
} }
// Check for retry/attempt errors // Check for retry/attempt errors
if (error.includes('attempts') || error.includes('Please check your connection')) { if (error.includes('attempts') || error.includes('Please check your connection')) {
return { code: 'CONNECTION', message: 'Connection error', userMessage: 'Please check your internet connection and try again.' }; return { code: 'CONNECTION', message: t('metadata.connection_error'), userMessage: t('metadata.network_error_desc') };
} }
// Check for streams-related errors // Check for streams-related errors
if (error.includes('streams') || error.includes('Failed to load streams')) { if (error.includes('streams') || error.includes('Failed to load streams')) {
return { code: 'STREAMS', message: 'Streams unavailable', userMessage: 'Streaming sources are currently unavailable. Please try again later.' }; return { code: 'STREAMS', message: t('metadata.streams_unavailable'), userMessage: t('metadata.streams_unavailable_desc') };
} }
// Default case // Default case
return { code: 'UNKNOWN', message: 'Unknown error', userMessage: 'An unexpected error occurred. Please try again.' }; return { code: 'UNKNOWN', message: t('metadata.unknown_error'), userMessage: t('metadata.something_went_wrong') };
}; };
const errorInfo = parseError(metadataError); const errorInfo = parseError(metadataError);
@ -852,10 +854,10 @@ const MetadataScreen: React.FC = () => {
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<MaterialIcons name="error-outline" size={64} color={currentTheme.colors.error || '#FF6B6B'} /> <MaterialIcons name="error-outline" size={64} color={currentTheme.colors.error || '#FF6B6B'} />
<Text style={[styles.errorTitle, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.errorTitle, { color: currentTheme.colors.highEmphasis }]}>
Unable to Load Content {t('metadata.unable_to_load')}
</Text> </Text>
<Text style={[styles.errorCode, { color: currentTheme.colors.textMuted }]}> <Text style={[styles.errorCode, { color: currentTheme.colors.textMuted }]}>
Error Code: {errorInfo.code} {t('metadata.error_code', { code: errorInfo.code })}
</Text> </Text>
<Text style={[styles.errorMessage, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.errorMessage, { color: currentTheme.colors.highEmphasis }]}>
{errorInfo.userMessage} {errorInfo.userMessage}
@ -870,13 +872,13 @@ const MetadataScreen: React.FC = () => {
onPress={loadMetadata} onPress={loadMetadata}
> >
<MaterialIcons name="refresh" size={20} color={currentTheme.colors.white} style={{ marginRight: 8 }} /> <MaterialIcons name="refresh" size={20} color={currentTheme.colors.white} style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}>Try Again</Text> <Text style={styles.retryButtonText}>{t('common.try_again')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.backButton, { borderColor: currentTheme.colors.primary }]} style={[styles.backButton, { borderColor: currentTheme.colors.primary }]}
onPress={handleBack} onPress={handleBack}
> >
<Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>Go Back</Text> <Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>{t('common.go_back')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</SafeAreaView> </SafeAreaView>
@ -1023,7 +1025,7 @@ const MetadataScreen: React.FC = () => {
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16, fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12 marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
} }
]}>Network</Text> ]}>{t('metadata.network')}</Text>
<View style={[ <View style={[
styles.productionRow, styles.productionRow,
{ {
@ -1093,7 +1095,7 @@ const MetadataScreen: React.FC = () => {
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16, fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12 marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
} }
]}>Production</Text> ]}>{t('metadata.production')}</Text>
<View style={[ <View style={[
styles.productionRow, styles.productionRow,
{ {
@ -1161,11 +1163,11 @@ const MetadataScreen: React.FC = () => {
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16, fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12 marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
} }
]}>Movie Details</Text> ]}>{t('metadata.movie_details')}</Text>
{metadata.movieDetails.tagline && ( {metadata.movieDetails.tagline && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Tagline</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.tagline')}</Text>
<Text style={[styles.tvDetailValue, { fontStyle: 'italic', fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}> <Text style={[styles.tvDetailValue, { fontStyle: 'italic', fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
"{metadata.movieDetails.tagline}" "{metadata.movieDetails.tagline}"
</Text> </Text>
@ -1174,14 +1176,14 @@ const MetadataScreen: React.FC = () => {
{metadata.movieDetails.status && ( {metadata.movieDetails.status && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Status</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.status')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.status}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.status}</Text>
</View> </View>
)} )}
{metadata.movieDetails.releaseDate && ( {metadata.movieDetails.releaseDate && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Release Date</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.release_date')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{new Date(metadata.movieDetails.releaseDate).toLocaleDateString('en-US', { {new Date(metadata.movieDetails.releaseDate).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
@ -1194,7 +1196,7 @@ const MetadataScreen: React.FC = () => {
{metadata.movieDetails.runtime && ( {metadata.movieDetails.runtime && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Runtime</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.runtime')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{Math.floor(metadata.movieDetails.runtime / 60)}h {metadata.movieDetails.runtime % 60}m {Math.floor(metadata.movieDetails.runtime / 60)}h {metadata.movieDetails.runtime % 60}m
</Text> </Text>
@ -1203,7 +1205,7 @@ const MetadataScreen: React.FC = () => {
{metadata.movieDetails.budget && metadata.movieDetails.budget > 0 && ( {metadata.movieDetails.budget && metadata.movieDetails.budget > 0 && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Budget</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.budget')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
${metadata.movieDetails.budget.toLocaleString()} ${metadata.movieDetails.budget.toLocaleString()}
</Text> </Text>
@ -1212,7 +1214,7 @@ const MetadataScreen: React.FC = () => {
{metadata.movieDetails.revenue && metadata.movieDetails.revenue > 0 && ( {metadata.movieDetails.revenue && metadata.movieDetails.revenue > 0 && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Revenue</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.revenue')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
${metadata.movieDetails.revenue.toLocaleString()} ${metadata.movieDetails.revenue.toLocaleString()}
</Text> </Text>
@ -1221,14 +1223,14 @@ const MetadataScreen: React.FC = () => {
{metadata.movieDetails.originCountry && metadata.movieDetails.originCountry.length > 0 && ( {metadata.movieDetails.originCountry && metadata.movieDetails.originCountry.length > 0 && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Origin Country</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.origin_country')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.originCountry.join(', ')}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.originCountry.join(', ')}</Text>
</View> </View>
)} )}
{metadata.movieDetails.originalLanguage && ( {metadata.movieDetails.originalLanguage && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Original Language</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.original_language')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.originalLanguage.toUpperCase()}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.movieDetails.originalLanguage.toUpperCase()}</Text>
</View> </View>
)} )}
@ -1246,7 +1248,7 @@ const MetadataScreen: React.FC = () => {
title: metadata.name || 'Gallery' title: metadata.name || 'Gallery'
})} })}
> >
<Text style={[styles.backdropGalleryText, { color: currentTheme.colors.highEmphasis }]}>Backdrop Gallery</Text> <Text style={[styles.backdropGalleryText, { color: currentTheme.colors.highEmphasis }]}>{t('metadata.backdrop_gallery')}</Text>
<MaterialIcons name="chevron-right" size={24} color={currentTheme.colors.highEmphasis} /> <MaterialIcons name="chevron-right" size={24} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -1292,18 +1294,18 @@ const MetadataScreen: React.FC = () => {
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16, fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12 marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
} }
]}>Show Details</Text> ]}>{t('metadata.show_details')}</Text>
{metadata.tvDetails.status && ( {metadata.tvDetails.status && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Status</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.status')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.status}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.status}</Text>
</View> </View>
)} )}
{metadata.tvDetails.firstAirDate && ( {metadata.tvDetails.firstAirDate && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>First Air Date</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.first_air_date')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{new Date(metadata.tvDetails.firstAirDate).toLocaleDateString('en-US', { {new Date(metadata.tvDetails.firstAirDate).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
@ -1316,7 +1318,7 @@ const MetadataScreen: React.FC = () => {
{metadata.tvDetails.lastAirDate && ( {metadata.tvDetails.lastAirDate && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Last Air Date</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.last_air_date')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{new Date(metadata.tvDetails.lastAirDate).toLocaleDateString('en-US', { {new Date(metadata.tvDetails.lastAirDate).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
@ -1329,21 +1331,21 @@ const MetadataScreen: React.FC = () => {
{metadata.tvDetails.numberOfSeasons && ( {metadata.tvDetails.numberOfSeasons && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Seasons</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.seasons')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.numberOfSeasons}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.numberOfSeasons}</Text>
</View> </View>
)} )}
{metadata.tvDetails.numberOfEpisodes && ( {metadata.tvDetails.numberOfEpisodes && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Total Episodes</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.total_episodes')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.numberOfEpisodes}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.numberOfEpisodes}</Text>
</View> </View>
)} )}
{metadata.tvDetails.episodeRunTime && metadata.tvDetails.episodeRunTime.length > 0 && ( {metadata.tvDetails.episodeRunTime && metadata.tvDetails.episodeRunTime.length > 0 && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Episode Runtime</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.episode_runtime')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{metadata.tvDetails.episodeRunTime.join(' - ')} min {metadata.tvDetails.episodeRunTime.join(' - ')} min
</Text> </Text>
@ -1352,21 +1354,21 @@ const MetadataScreen: React.FC = () => {
{metadata.tvDetails.originCountry && metadata.tvDetails.originCountry.length > 0 && ( {metadata.tvDetails.originCountry && metadata.tvDetails.originCountry.length > 0 && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Origin Country</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.origin_country')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.originCountry.join(', ')}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.originCountry.join(', ')}</Text>
</View> </View>
)} )}
{metadata.tvDetails.originalLanguage && ( {metadata.tvDetails.originalLanguage && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Original Language</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.original_language')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.originalLanguage.toUpperCase()}</Text> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{metadata.tvDetails.originalLanguage.toUpperCase()}</Text>
</View> </View>
)} )}
{metadata.tvDetails.createdBy && metadata.tvDetails.createdBy.length > 0 && ( {metadata.tvDetails.createdBy && metadata.tvDetails.createdBy.length > 0 && (
<View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}> <View style={[styles.tvDetailRow, { paddingVertical: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8 }]}>
<Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>Created By</Text> <Text style={[styles.tvDetailLabel, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>{t('metadata.created_by')}</Text>
<Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}> <Text style={[styles.tvDetailValue, { fontSize: isTV ? 15 : isLargeTablet ? 14 : isTablet ? 14 : 14 }]}>
{metadata.tvDetails.createdBy.map(creator => creator.name).join(', ')} {metadata.tvDetails.createdBy.map(creator => creator.name).join(', ')}
</Text> </Text>
@ -1386,7 +1388,7 @@ const MetadataScreen: React.FC = () => {
title: metadata.name || 'Gallery' title: metadata.name || 'Gallery'
})} })}
> >
<Text style={[styles.backdropGalleryText, { color: currentTheme.colors.highEmphasis }]}>Backdrop Gallery</Text> <Text style={[styles.backdropGalleryText, { color: currentTheme.colors.highEmphasis }]}>{t('metadata.backdrop_gallery')}</Text>
<MaterialIcons name="chevron-right" size={24} color={currentTheme.colors.highEmphasis} /> <MaterialIcons name="chevron-right" size={24} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View file

@ -17,10 +17,12 @@ import { notificationService, NotificationSettings } from '../services/notificat
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { useTranslation } from 'react-i18next';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const NotificationSettingsScreen = () => { const NotificationSettingsScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation(); const navigation = useNavigation();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const [settings, setSettings] = useState<NotificationSettings>({ const [settings, setSettings] = useState<NotificationSettings>({
@ -47,7 +49,7 @@ const NotificationSettingsScreen = () => {
try { try {
const savedSettings = await notificationService.getSettings(); const savedSettings = await notificationService.getSettings();
setSettings(savedSettings); setSettings(savedSettings);
// Load notification stats // Load notification stats
const stats = notificationService.getNotificationStats(); const stats = notificationService.getNotificationStats();
setNotificationStats(stats); setNotificationStats(stats);
@ -72,7 +74,7 @@ const NotificationSettingsScreen = () => {
// Add countdown effect // Add countdown effect
useEffect(() => { useEffect(() => {
let intervalId: NodeJS.Timeout; let intervalId: NodeJS.Timeout;
if (countdown !== null && countdown > 0) { if (countdown !== null && countdown > 0) {
intervalId = setInterval(() => { intervalId = setInterval(() => {
setCountdown(prev => prev !== null ? prev - 1 : null); setCountdown(prev => prev !== null ? prev - 1 : null);
@ -96,23 +98,23 @@ const NotificationSettingsScreen = () => {
...settings, ...settings,
[key]: value, [key]: value,
}; };
// Special case: if enabling notifications, make sure permissions are granted // Special case: if enabling notifications, make sure permissions are granted
if (key === 'enabled' && value === true) { if (key === 'enabled' && value === true) {
// Permissions are handled in the service // Permissions are handled in the service
} }
// Update settings in the service // Update settings in the service
await notificationService.updateSettings({ [key]: value }); await notificationService.updateSettings({ [key]: value });
// Update local state // Update local state
setSettings(updatedSettings); setSettings(updatedSettings);
} catch (error) { } catch (error) {
logger.error('Error updating notification settings:', error); logger.error('Error updating notification settings:', error);
setAlertTitle('Error'); setAlertTitle('Error');
setAlertMessage('Failed to update notification settings'); setAlertMessage('Failed to update notification settings');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} }
}; };
@ -122,20 +124,20 @@ const NotificationSettingsScreen = () => {
}; };
const resetAllNotifications = async () => { const resetAllNotifications = async () => {
setAlertTitle('Reset Notifications'); setAlertTitle(t('notification.alert_reset_title'));
setAlertMessage('This will cancel all scheduled notifications, but will not remove anything from your saved library. Are you sure?'); setAlertMessage(t('notification.alert_reset_msg'));
setAlertActions([ setAlertActions([
{ label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: currentTheme.colors.mediumGray } }, { label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: currentTheme.colors.mediumGray } },
{ {
label: 'Reset', label: t('mdblist.reset_confirm') || 'Reset', // Using mdblist or common if available, fallback for safely
onPress: async () => { onPress: async () => {
try { try {
const scheduledNotifications = notificationService.getScheduledNotifications?.() || []; const scheduledNotifications = notificationService.getScheduledNotifications?.() || [];
for (const notification of scheduledNotifications) { for (const notification of scheduledNotifications) {
await notificationService.cancelNotification(notification.id); await notificationService.cancelNotification(notification.id);
} }
setAlertTitle('Success'); setAlertTitle(t('common.success') || 'Success');
setAlertMessage('All notifications have been reset'); setAlertMessage(t('notification.alert_reset_success'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} catch (error) { } catch (error) {
@ -154,25 +156,25 @@ const NotificationSettingsScreen = () => {
const handleSyncNotifications = async () => { const handleSyncNotifications = async () => {
if (isSyncing) return; if (isSyncing) return;
setIsSyncing(true); setIsSyncing(true);
try { try {
await notificationService.syncAllNotifications(); await notificationService.syncAllNotifications();
// Refresh stats after sync // Refresh stats after sync
const stats = notificationService.getNotificationStats(); const stats = notificationService.getNotificationStats();
setNotificationStats(stats); setNotificationStats(stats);
setAlertTitle('Sync Complete'); setAlertTitle(t('notification.alert_sync_complete'));
setAlertMessage(`Successfully synced notifications for your library and Trakt items.\n\nScheduled: ${stats.upcoming} upcoming episodes\nThis week: ${stats.thisWeek} episodes`); setAlertMessage(t('notification.alert_sync_msg', { upcoming: stats.upcoming, thisWeek: stats.thisWeek }));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} catch (error) { } catch (error) {
logger.error('Error syncing notifications:', error); logger.error('Error syncing notifications:', error);
setAlertTitle('Error'); setAlertTitle('Error');
setAlertMessage('Failed to sync notifications. Please try again.'); setAlertMessage('Failed to sync notifications. Please try again.');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} finally { } finally {
setIsSyncing(false); setIsSyncing(false);
} }
@ -224,22 +226,22 @@ const NotificationSettingsScreen = () => {
if (notificationId) { if (notificationId) {
setTestNotificationId(notificationId); setTestNotificationId(notificationId);
setCountdown(0); // No countdown for instant notification setCountdown(0); // No countdown for instant notification
setAlertTitle('Success'); setAlertTitle(t('common.success') || 'Success');
setAlertMessage('Test notification scheduled to fire instantly'); setAlertMessage(t('notification.alert_test_scheduled'));
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} else { } else {
setAlertTitle('Error'); setAlertTitle('Error');
setAlertMessage('Failed to schedule test notification. Make sure notifications are enabled.'); setAlertMessage('Failed to schedule test notification. Make sure notifications are enabled.');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} }
} catch (error) { } catch (error) {
logger.error('Error scheduling test notification:', error); logger.error('Error scheduling test notification:', error);
setAlertTitle('Error'); setAlertTitle('Error');
setAlertMessage('Failed to schedule test notification'); setAlertMessage('Failed to schedule test notification');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true); setAlertVisible(true);
} }
}; };
@ -247,13 +249,13 @@ const NotificationSettingsScreen = () => {
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}> <View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}>
<TouchableOpacity <TouchableOpacity
style={styles.backButton} style={styles.backButton}
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} /> <MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Notification Settings</Text> <Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>{t('notification.title')}</Text>
<View style={{ width: 40 }} /> <View style={{ width: 40 }} />
</View> </View>
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
@ -266,39 +268,39 @@ const NotificationSettingsScreen = () => {
return ( return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity <TouchableOpacity
style={styles.backButton} style={styles.backButton}
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} /> <MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
<Text style={[styles.backText, { color: currentTheme.colors.text }]}> <Text style={[styles.backText, { color: currentTheme.colors.text }]}>
Settings {t('common.settings') || 'Settings'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerActions}> <View style={styles.headerActions}>
{/* Empty for now, but ready for future actions */} {/* Empty for now, but ready for future actions */}
</View> </View>
</View> </View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}> <Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Notification Settings {t('notification.title')}
</Text> </Text>
<ScrollView style={styles.content}> <ScrollView style={styles.content}>
<Animated.View <Animated.View
entering={FadeIn.duration(300)} entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)} exiting={FadeOut.duration(200)}
> >
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}> <View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>General</Text> <Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>{t('notification.section_general')}</Text>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}> <View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<MaterialIcons name="notifications" size={24} color={currentTheme.colors.text} /> <MaterialIcons name="notifications" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>Enable Notifications</Text> <Text style={[styles.settingText, { color: currentTheme.colors.text }]}>{t('notification.enable_notifications')}</Text>
</View> </View>
<Switch <Switch
value={settings.enabled} value={settings.enabled}
@ -308,16 +310,16 @@ const NotificationSettingsScreen = () => {
/> />
</View> </View>
</View> </View>
{settings.enabled && ( {settings.enabled && (
<> <>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}> <View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Notification Types</Text> <Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>{t('notification.section_types')}</Text>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}> <View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<MaterialIcons name="new-releases" size={24} color={currentTheme.colors.text} /> <MaterialIcons name="new-releases" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>New Episodes</Text> <Text style={[styles.settingText, { color: currentTheme.colors.text }]}>{t('notification.new_episodes')}</Text>
</View> </View>
<Switch <Switch
value={settings.newEpisodeNotifications} value={settings.newEpisodeNotifications}
@ -326,11 +328,11 @@ const NotificationSettingsScreen = () => {
thumbColor={settings.newEpisodeNotifications ? currentTheme.colors.primary : currentTheme.colors.lightGray} thumbColor={settings.newEpisodeNotifications ? currentTheme.colors.primary : currentTheme.colors.lightGray}
/> />
</View> </View>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}> <View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<MaterialIcons name="event" size={24} color={currentTheme.colors.text} /> <MaterialIcons name="event" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>Upcoming Shows</Text> <Text style={[styles.settingText, { color: currentTheme.colors.text }]}>{t('notification.upcoming_shows')}</Text>
</View> </View>
<Switch <Switch
value={settings.upcomingShowsNotifications} value={settings.upcomingShowsNotifications}
@ -339,11 +341,11 @@ const NotificationSettingsScreen = () => {
thumbColor={settings.upcomingShowsNotifications ? currentTheme.colors.primary : currentTheme.colors.lightGray} thumbColor={settings.upcomingShowsNotifications ? currentTheme.colors.primary : currentTheme.colors.lightGray}
/> />
</View> </View>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}> <View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<MaterialIcons name="alarm" size={24} color={currentTheme.colors.text} /> <MaterialIcons name="alarm" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>Reminders</Text> <Text style={[styles.settingText, { color: currentTheme.colors.text }]}>{t('notification.reminders')}</Text>
</View> </View>
<Switch <Switch
value={settings.reminderNotifications} value={settings.reminderNotifications}
@ -353,23 +355,23 @@ const NotificationSettingsScreen = () => {
/> />
</View> </View>
</View> </View>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}> <View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Notification Timing</Text> <Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>{t('notification.section_timing')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.lightGray }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.lightGray }]}>
When should you be notified before an episode airs? {t('notification.timing_desc')}
</Text> </Text>
<View style={styles.timingOptions}> <View style={styles.timingOptions}>
{[1, 6, 12, 24].map((hours) => ( {[1, 6, 12, 24].map((hours) => (
<TouchableOpacity <TouchableOpacity
key={hours} key={hours}
style={[ style={[
styles.timingOption, styles.timingOption,
{ {
backgroundColor: currentTheme.colors.elevation1, backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border borderColor: currentTheme.colors.border
}, },
settings.timeBeforeAiring === hours && { settings.timeBeforeAiring === hours && {
backgroundColor: currentTheme.colors.primary + '30', backgroundColor: currentTheme.colors.primary + '30',
@ -386,38 +388,38 @@ const NotificationSettingsScreen = () => {
fontWeight: 'bold', fontWeight: 'bold',
} }
]}> ]}>
{hours === 1 ? '1 hour' : `${hours} hours`} {hours === 1 ? t('notification.hours_1') : `${hours} ${t('notification.hours_suffix')}`}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
</View> </View>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}> <View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Notification Status</Text> <Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>{t('notification.section_status')}</Text>
<View style={[styles.statsContainer, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.statsContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.statItem}> <View style={styles.statItem}>
<MaterialIcons name="schedule" size={20} color={currentTheme.colors.primary} /> <MaterialIcons name="schedule" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>Upcoming</Text> <Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>{t('notification.stats_upcoming')}</Text>
<Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.upcoming}</Text> <Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.upcoming}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<MaterialIcons name="today" size={20} color={currentTheme.colors.primary} /> <MaterialIcons name="today" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>This Week</Text> <Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>{t('notification.stats_this_week')}</Text>
<Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.thisWeek}</Text> <Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.thisWeek}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<MaterialIcons name="notifications-active" size={20} color={currentTheme.colors.primary} /> <MaterialIcons name="notifications-active" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>Total</Text> <Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>{t('notification.stats_total')}</Text>
<Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.total}</Text> <Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.total}</Text>
</View> </View>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.resetButton, styles.resetButton,
{ {
backgroundColor: currentTheme.colors.primary + '20', backgroundColor: currentTheme.colors.primary + '20',
borderColor: currentTheme.colors.primary + '50' borderColor: currentTheme.colors.primary + '50'
} }
@ -425,29 +427,29 @@ const NotificationSettingsScreen = () => {
onPress={handleSyncNotifications} onPress={handleSyncNotifications}
disabled={isSyncing} disabled={isSyncing}
> >
<MaterialIcons <MaterialIcons
name={isSyncing ? "sync" : "sync"} name={isSyncing ? "sync" : "sync"}
size={24} size={24}
color={currentTheme.colors.primary} color={currentTheme.colors.primary}
style={isSyncing ? { transform: [{ rotate: '360deg' }] } : {}} style={isSyncing ? { transform: [{ rotate: '360deg' }] } : {}}
/> />
<Text style={[styles.resetButtonText, { color: currentTheme.colors.primary }]}> <Text style={[styles.resetButtonText, { color: currentTheme.colors.primary }]}>
{isSyncing ? 'Syncing...' : 'Sync Library & Trakt'} {isSyncing ? t('notification.syncing') : t('notification.sync_button')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={[styles.resetDescription, { color: currentTheme.colors.lightGray }]}> <Text style={[styles.resetDescription, { color: currentTheme.colors.lightGray }]}>
Automatically syncs notifications for all shows in your library and Trakt watchlist/collection. {t('notification.sync_desc')}
</Text> </Text>
</View> </View>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}> <View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Advanced</Text> <Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>{t('notification.section_advanced')}</Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.resetButton, styles.resetButton,
{ {
backgroundColor: currentTheme.colors.error + '20', backgroundColor: currentTheme.colors.error + '20',
borderColor: currentTheme.colors.error + '50' borderColor: currentTheme.colors.error + '50'
} }
@ -455,13 +457,13 @@ const NotificationSettingsScreen = () => {
onPress={resetAllNotifications} onPress={resetAllNotifications}
> >
<MaterialIcons name="refresh" size={24} color={currentTheme.colors.error} /> <MaterialIcons name="refresh" size={24} color={currentTheme.colors.error} />
<Text style={[styles.resetButtonText, { color: currentTheme.colors.error }]}>Reset All Notifications</Text> <Text style={[styles.resetButtonText, { color: currentTheme.colors.error }]}>{t('notification.reset_button')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.resetButton, styles.resetButton,
{ {
marginTop: 12, marginTop: 12,
backgroundColor: currentTheme.colors.primary + '20', backgroundColor: currentTheme.colors.primary + '20',
borderColor: currentTheme.colors.primary + '50' borderColor: currentTheme.colors.primary + '50'
@ -472,22 +474,22 @@ const NotificationSettingsScreen = () => {
> >
<MaterialIcons name="bug-report" size={24} color={currentTheme.colors.primary} /> <MaterialIcons name="bug-report" size={24} color={currentTheme.colors.primary} />
<Text style={[styles.resetButtonText, { color: currentTheme.colors.primary }]}> <Text style={[styles.resetButtonText, { color: currentTheme.colors.primary }]}>
{countdown !== null {countdown !== null
? `Notification in ${countdown}s...` ? t('notification.test_notification_in', { seconds: countdown })
: 'Test Notification (5 sec)'} : t('notification.test_button')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{countdown !== null && ( {countdown !== null && (
<View style={styles.countdownContainer}> <View style={styles.countdownContainer}>
<MaterialIcons <MaterialIcons
name="timer" name="timer"
size={16} size={16}
color={currentTheme.colors.primary} color={currentTheme.colors.primary}
style={styles.countdownIcon} style={styles.countdownIcon}
/> />
<Text style={[styles.countdownText, { color: currentTheme.colors.primary }]}> <Text style={[styles.countdownText, { color: currentTheme.colors.primary }]}>
Notification will appear in {countdown} seconds {t('notification.test_notification_text', { seconds: countdown })}
</Text> </Text>
</View> </View>
)} )}
@ -496,14 +498,14 @@ const NotificationSettingsScreen = () => {
)} )}
</Animated.View> </Animated.View>
</ScrollView> </ScrollView>
<CustomAlert <CustomAlert
visible={alertVisible} visible={alertVisible}
title={alertTitle} title={alertTitle}
message={alertMessage} message={alertMessage}
onClose={() => setAlertVisible(false)} onClose={() => setAlertVisible(false)}
actions={alertActions} actions={alertActions}
/> />
</SafeAreaView> </SafeAreaView>
); );
}; };

View file

@ -15,6 +15,7 @@ import { useSettings, AppSettings } from '../hooks/useSettings';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import CustomAlert from '../components/CustomAlert'; import CustomAlert from '../components/CustomAlert';
import { useTranslation } from 'react-i18next';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -95,6 +96,7 @@ const PlayerSettingsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const navigation = useNavigation(); const navigation = useNavigation();
const { t } = useTranslation();
// CustomAlert state // CustomAlert state
const [alertVisible, setAlertVisible] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
@ -110,46 +112,46 @@ const PlayerSettingsScreen: React.FC = () => {
const playerOptions = [ const playerOptions = [
{ {
id: 'internal', id: 'internal',
title: 'Built-in Player', title: t('player.internal_title'),
description: 'Use the app\'s default video player', description: t('player.internal_desc'),
icon: 'play-circle-outline', icon: 'play-circle-outline',
}, },
...(Platform.OS === 'ios' ? [ ...(Platform.OS === 'ios' ? [
{ {
id: 'vlc', id: 'vlc',
title: 'VLC', title: t('player.vlc_title'),
description: 'Open streams in VLC media player', description: t('player.vlc_desc'),
icon: 'video-library', icon: 'video-library',
}, },
{ {
id: 'infuse', id: 'infuse',
title: 'Infuse', title: t('player.infuse_title'),
description: 'Open streams in Infuse player', description: t('player.infuse_desc'),
icon: 'smart-display', icon: 'smart-display',
}, },
{ {
id: 'outplayer', id: 'outplayer',
title: 'OutPlayer', title: t('player.outplayer_title'),
description: 'Open streams in OutPlayer', description: t('player.outplayer_desc'),
icon: 'slideshow', icon: 'slideshow',
}, },
{ {
id: 'vidhub', id: 'vidhub',
title: 'VidHub', title: t('player.vidhub_title'),
description: 'Open streams in VidHub player', description: t('player.vidhub_desc'),
icon: 'ondemand-video', icon: 'ondemand-video',
}, },
{ {
id: 'infuse_livecontainer', id: 'infuse_livecontainer',
title: 'Infuse Livecontainer', title: t('player.infuse_live_title'),
description: 'Open streams in Infuse player LiveContainer', description: t('player.infuse_live_desc'),
icon: 'smart-display', icon: 'smart-display',
}, },
] : [ ] : [
{ {
id: 'external', id: 'external',
title: 'External Player', title: t('player.external_title'),
description: 'Open streams in your preferred video player', description: t('player.external_desc'),
icon: 'open-in-new', icon: 'open-in-new',
}, },
]), ]),
@ -184,7 +186,7 @@ const PlayerSettingsScreen: React.FC = () => {
color={currentTheme.colors.text} color={currentTheme.colors.text}
/> />
<Text style={[styles.backText, { color: currentTheme.colors.text }]}> <Text style={[styles.backText, { color: currentTheme.colors.text }]}>
Settings {t('common.settings') || 'Settings'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -194,7 +196,7 @@ const PlayerSettingsScreen: React.FC = () => {
</View> </View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}> <Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Video Player {t('player.title')}
</Text> </Text>
<ScrollView <ScrollView
@ -208,7 +210,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted }, { color: currentTheme.colors.textMuted },
]} ]}
> >
PLAYER SELECTION {t('player.section_selection')}
</Text> </Text>
<View <View
style={[ style={[
@ -249,7 +251,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted }, { color: currentTheme.colors.textMuted },
]} ]}
> >
PLAYBACK OPTIONS {t('player.section_playback')}
</Text> </Text>
<View <View
style={[ style={[
@ -278,7 +280,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text }, { color: currentTheme.colors.text },
]} ]}
> >
Auto-play Best Stream {t('player.autoplay_title')}
</Text> </Text>
<Text <Text
style={[ style={[
@ -286,7 +288,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted }, { color: currentTheme.colors.textMuted },
]} ]}
> >
Automatically start the highest quality stream available. {t('player.autoplay_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -316,7 +318,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text }, { color: currentTheme.colors.text },
]} ]}
> >
Always Resume {t('player.resume_title')}
</Text> </Text>
<Text <Text
style={[ style={[
@ -324,7 +326,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted }, { color: currentTheme.colors.textMuted },
]} ]}
> >
Skip the resume prompt and automatically continue where you left off (if less than 85% watched). {t('player.resume_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -357,7 +359,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text }, { color: currentTheme.colors.text },
]} ]}
> >
Video Player Engine {t('player.engine_title')}
</Text> </Text>
<Text <Text
style={[ style={[
@ -365,14 +367,14 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted }, { color: currentTheme.colors.textMuted },
]} ]}
> >
Auto uses ExoPlayer with MPV fallback. Some formats like Dolby Vision and HDR may not be supported by MPV, so Auto is recommended for best compatibility. {t('player.engine_desc')}
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.optionButtonsRow}> <View style={styles.optionButtonsRow}>
{([ {([
{ id: 'auto', label: 'Auto', desc: 'ExoPlayer + MPV fallback' }, { id: 'auto', label: t('player.option_auto'), desc: t('player.option_auto_desc_engine') },
{ id: 'mpv', label: 'MPV', desc: 'MPV only' }, { id: 'mpv', label: t('player.option_mpv'), desc: t('player.option_mpv_desc') },
] as const).map((option) => ( ] as const).map((option) => (
<TouchableOpacity <TouchableOpacity
key={option.id} key={option.id}
@ -416,7 +418,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text }, { color: currentTheme.colors.text },
]} ]}
> >
Decoder Mode {t('player.decoder_title')}
</Text> </Text>
<Text <Text
style={[ style={[
@ -424,24 +426,24 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted }, { color: currentTheme.colors.textMuted },
]} ]}
> >
How video is decoded. Auto is recommended for best balance. {t('player.decoder_desc')}
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.optionButtonsRow}> <View style={styles.optionButtonsRow}>
{([ {([
{ id: 'auto', label: 'Auto', desc: 'Best balance' }, { id: 'auto', label: t('player.option_auto'), desc: t('player.option_auto_desc_decoder') },
{ id: 'sw', label: 'SW', desc: 'Software' }, { id: 'sw', label: t('player.option_sw'), desc: t('player.option_sw_desc') },
{ id: 'hw', label: 'HW', desc: 'Hardware' }, { id: 'hw', label: t('player.option_hw'), desc: t('player.option_hw_desc') },
{ id: 'hw+', label: 'HW+', desc: 'Full HW' }, { id: 'hw+', label: t('player.option_hw_plus'), desc: t('player.option_hw_plus_desc') },
] as const).map((option) => ( ] as const).map((option) => (
<TouchableOpacity <TouchableOpacity
key={option.id} key={option.id}
onPress={() => { onPress={() => {
updateSetting('decoderMode', option.id); updateSetting('decoderMode', option.id);
openAlert( openAlert(
'Restart Required', t('player.restart_required'),
'Please restart the app for the decoder change to take effect.' t('player.restart_msg_decoder')
); );
}} }}
style={[ style={[
@ -482,7 +484,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text }, { color: currentTheme.colors.text },
]} ]}
> >
GPU Rendering {t('player.gpu_title')}
</Text> </Text>
<Text <Text
style={[ style={[
@ -490,22 +492,22 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted }, { color: currentTheme.colors.textMuted },
]} ]}
> >
GPU-Next offers better HDR and color management. {t('player.gpu_desc')}
</Text> </Text>
</View> </View>
</View> </View>
<View style={styles.optionButtonsRow}> <View style={styles.optionButtonsRow}>
{([ {([
{ id: 'gpu', label: 'GPU', desc: 'Standard' }, { id: 'gpu', label: t('player.option_gpu_desc') },
{ id: 'gpu-next', label: 'GPU-Next', desc: 'Advanced' }, { id: 'gpu-next', label: t('player.option_gpu_next_desc') },
] as const).map((option) => ( ] as const).map((option) => (
<TouchableOpacity <TouchableOpacity
key={option.id} key={option.id}
onPress={() => { onPress={() => {
updateSetting('gpuMode', option.id); updateSetting('gpuMode', option.id);
openAlert( openAlert(
'Restart Required', t('player.restart_required'),
'Please restart the app for the GPU mode change to take effect.' t('player.restart_msg_gpu')
); );
}} }}
style={[ style={[
@ -551,7 +553,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text }, { color: currentTheme.colors.text },
]} ]}
> >
External Player for Downloads {t('player.external_downloads_title')}
</Text> </Text>
<Text <Text
style={[ style={[
@ -559,7 +561,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted }, { color: currentTheme.colors.textMuted },
]} ]}
> >
Play downloaded content in your preferred external player. {t('player.external_downloads_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -580,7 +582,7 @@ const PlayerSettingsScreen: React.FC = () => {
message={alertMessage} message={alertMessage}
onClose={() => setAlertVisible(false)} onClose={() => setAlertVisible(false)}
/> />
</SafeAreaView> </SafeAreaView >
); );
}; };

View file

@ -25,6 +25,7 @@ import { useSettings } from '../hooks/useSettings';
import { localScraperService, pluginService, ScraperInfo, RepositoryInfo } from '../services/pluginService'; import { localScraperService, pluginService, ScraperInfo, RepositoryInfo } from '../services/pluginService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useTranslation } from 'react-i18next';
const { width: screenWidth } = Dimensions.get('window'); const { width: screenWidth } = Dimensions.get('window');
@ -902,6 +903,7 @@ const PluginsScreen: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
const colors = currentTheme.colors; const colors = currentTheme.colors;
const styles = createStyles(colors); const styles = createStyles(colors);
@ -1025,10 +1027,10 @@ const PluginsScreen: React.FC = () => {
); );
await Promise.all(promises); await Promise.all(promises);
await loadPlugins(); await loadPlugins();
openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredPlugins.length} plugins`); openAlert(t('plugins.success'), `${enabled ? t('plugins.enabled') : t('plugins.disabled')} ${filteredPlugins.length} plugins`);
} catch (error) { } catch (error) {
logger.error('[PluginSettings] Failed to bulk toggle:', error); logger.error('[PluginSettings] Failed to bulk toggle:', error);
openAlert('Error', 'Failed to update plugins'); openAlert(t('plugins.error'), 'Failed to update plugins');
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
} }
@ -1048,7 +1050,7 @@ const PluginsScreen: React.FC = () => {
const url = newRepositoryUrl.trim(); const url = newRepositoryUrl.trim();
if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) { if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) {
openAlert( openAlert(
'Invalid URL Format', t('plugins.alert_invalid_url'),
'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nor include manifest.json:\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch/manifest.json\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master' 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nor include manifest.json:\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch/manifest.json\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master'
); );
return; return;
@ -1089,10 +1091,10 @@ const PluginsScreen: React.FC = () => {
setNewRepositoryUrl(''); setNewRepositoryUrl('');
setShowAddRepositoryModal(false); setShowAddRepositoryModal(false);
openAlert('Success', 'Repository added and plugins loaded successfully'); openAlert(t('plugins.success'), t('plugins.alert_repo_added'));
} catch (error) { } catch (error) {
logger.error('[PluginsScreen] Failed to add repository:', error); logger.error('[PluginsScreen] Failed to add repository:', error);
openAlert('Error', 'Failed to add repository'); openAlert(t('plugins.error'), 'Failed to add repository');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -1113,10 +1115,10 @@ const PluginsScreen: React.FC = () => {
await loadPlugins(); await loadPlugins();
const repo = repositories.find(r => r.id === repoId); const repo = repositories.find(r => r.id === repoId);
openAlert('Success', `Repository "${repo?.name || 'Unknown'}" ${enabled ? 'enabled' : 'disabled'} successfully`); openAlert(t('plugins.success'), `Repository "${repo?.name || t('plugins.unknown')}" ${enabled ? t('plugins.enabled').toLowerCase() : t('plugins.disabled').toLowerCase()} successfully`);
} catch (error) { } catch (error) {
logger.error('[PluginSettings] Failed to toggle repository:', error); logger.error('[PluginSettings] Failed to toggle repository:', error);
openAlert('Error', 'Failed to update repository'); openAlert(t('plugins.error'), 'Failed to update repository');
} finally { } finally {
setSwitchingRepository(null); setSwitchingRepository(null);
} }
@ -1249,10 +1251,10 @@ const PluginsScreen: React.FC = () => {
await pluginService.setRepositoryUrl(url); await pluginService.setRepositoryUrl(url);
await updateSetting('scraperRepositoryUrl', url); await updateSetting('scraperRepositoryUrl', url);
setHasRepository(true); setHasRepository(true);
openAlert('Success', 'Repository URL saved successfully'); openAlert(t('plugins.success'), t('plugins.alert_repo_saved'));
} catch (error) { } catch (error) {
logger.error('[PluginSettings] Failed to save repository:', error); logger.error('[PluginSettings] Failed to save repository:', error);
openAlert('Error', 'Failed to save repository URL'); openAlert(t('plugins.error'), 'Failed to save repository URL');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -1274,7 +1276,7 @@ const PluginsScreen: React.FC = () => {
// Load fresh plugins from the updated repository // Load fresh plugins from the updated repository
await loadPlugins(); await loadPlugins();
openAlert('Success', 'Repository refreshed successfully with latest files'); openAlert(t('plugins.success'), t('plugins.alert_repo_refreshed'));
} catch (error) { } catch (error) {
logger.error('[PluginsScreen] Failed to refresh repository:', error); logger.error('[PluginsScreen] Failed to refresh repository:', error);
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
@ -1306,15 +1308,15 @@ const PluginsScreen: React.FC = () => {
await loadPlugins(); await loadPlugins();
} catch (error) { } catch (error) {
logger.error('[PluginSettings] Failed to toggle plugin:', error); logger.error('[PluginSettings] Failed to toggle plugin:', error);
openAlert('Error', 'Failed to update plugin status'); openAlert(t('plugins.error'), 'Failed to update plugin status');
setIsRefreshing(false); setIsRefreshing(false);
} }
}; };
const handleClearPlugins = () => { const handleClearPlugins = () => {
openAlert( openAlert(
'Clear All Plugins', t('plugins.clear_all'),
'Are you sure you want to remove all installed plugins? This action cannot be undone.', t('plugins.clear_all_desc'),
[ [
{ label: 'Cancel', onPress: () => { } }, { label: 'Cancel', onPress: () => { } },
{ {
@ -1323,10 +1325,10 @@ const PluginsScreen: React.FC = () => {
try { try {
await pluginService.clearScrapers(); await pluginService.clearScrapers();
await loadPlugins(); await loadPlugins();
openAlert('Success', 'All plugins have been removed'); openAlert(t('plugins.success'), t('plugins.alert_plugins_cleared'));
} catch (error) { } catch (error) {
logger.error('[PluginSettings] Failed to clear plugins:', error); logger.error('[PluginSettings] Failed to clear plugins:', error);
openAlert('Error', 'Failed to clear plugins'); openAlert(t('plugins.error'), 'Failed to clear plugins');
} }
}, },
}, },
@ -1336,8 +1338,8 @@ const PluginsScreen: React.FC = () => {
const handleClearPluginCache = () => { const handleClearPluginCache = () => {
openAlert( openAlert(
'Clear Repository Cache', t('plugins.clear_cache'),
'This will remove the saved repository URL and clear all cached plugin data. You will need to re-enter your repository URL.', t('plugins.clear_cache_desc'),
[ [
{ label: 'Cancel', onPress: () => { } }, { label: 'Cancel', onPress: () => { } },
{ {
@ -1350,10 +1352,10 @@ const PluginsScreen: React.FC = () => {
setRepositoryUrl(''); setRepositoryUrl('');
setHasRepository(false); setHasRepository(false);
await loadPlugins(); await loadPlugins();
openAlert('Success', 'Repository cache cleared successfully'); openAlert(t('plugins.success'), t('plugins.alert_cache_cleared'));
} catch (error) { } catch (error) {
logger.error('[PluginSettings] Failed to clear cache:', error); logger.error('[PluginSettings] Failed to clear cache:', error);
openAlert('Error', 'Failed to clear repository cache'); openAlert(t('plugins.error'), 'Failed to clear repository cache');
} }
}, },
}, },
@ -1446,7 +1448,7 @@ const PluginsScreen: React.FC = () => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<Ionicons name="arrow-back" size={24} color={colors.primary} /> <Ionicons name="arrow-back" size={24} color={colors.primary} />
<Text style={styles.backText}>Settings</Text> <Text style={styles.backText}>{t('settings.title')}</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerActions}> <View style={styles.headerActions}>
@ -1460,7 +1462,7 @@ const PluginsScreen: React.FC = () => {
</View> </View>
</View> </View>
<Text style={styles.headerTitle}>Plugins</Text> <Text style={styles.headerTitle}>{t('plugins.title')}</Text>
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
@ -1490,7 +1492,7 @@ const PluginsScreen: React.FC = () => {
{/* Enable Plugins */} {/* Enable Plugins */}
<CollapsibleSection <CollapsibleSection
title="Enable Plugins" title={t('plugins.enable_title')}
isExpanded={expandedSections.repository} isExpanded={expandedSections.repository}
onToggle={() => toggleSection('repository')} onToggle={() => toggleSection('repository')}
colors={colors} colors={colors}
@ -1498,9 +1500,9 @@ const PluginsScreen: React.FC = () => {
> >
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Enable Plugins</Text> <Text style={styles.settingTitle}>{t('plugins.enable_title')}</Text>
<Text style={styles.settingDescription}> <Text style={styles.settingDescription}>
Allow the app to use installed plugins for finding streams {t('plugins.enable_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -1514,22 +1516,22 @@ const PluginsScreen: React.FC = () => {
{/* Repository Configuration */} {/* Repository Configuration */}
<CollapsibleSection <CollapsibleSection
title="Repository Configuration" title={t('plugins.repo_config_title')}
isExpanded={expandedSections.repository} isExpanded={expandedSections.repository}
onToggle={() => toggleSection('repository')} onToggle={() => toggleSection('repository')}
colors={colors} colors={colors}
styles={styles} styles={styles}
> >
<Text style={styles.sectionDescription}> <Text style={styles.sectionDescription}>
Enable multiple repositories to combine plugins from different sources. Toggle each repository on or off below. {t('plugins.repo_config_desc')}
</Text> </Text>
{/* Repository List */} {/* Repository List */}
{repositories.length > 0 && ( {repositories.length > 0 && (
<View style={styles.repositoriesList}> <View style={styles.repositoriesList}>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>Your Repositories</Text> <Text style={[styles.settingTitle, { marginBottom: 8 }]}>{t('plugins.your_repos')}</Text>
<Text style={[styles.settingDescription, { marginBottom: 12 }]}> <Text style={[styles.settingDescription, { marginBottom: 12 }]}>
Enable multiple repositories to combine plugins from different sources. {t('plugins.your_repos_desc')}
</Text> </Text>
{repositories.map((repo) => ( {repositories.map((repo) => (
<View key={repo.id} style={[styles.repositoryItem, repo.enabled === false && { opacity: 0.6 }]}> <View key={repo.id} style={[styles.repositoryItem, repo.enabled === false && { opacity: 0.6 }]}>
@ -1539,13 +1541,13 @@ const PluginsScreen: React.FC = () => {
{repo.enabled !== false && ( {repo.enabled !== false && (
<View style={[styles.statusBadge, { backgroundColor: '#34C759' }]}> <View style={[styles.statusBadge, { backgroundColor: '#34C759' }]}>
<Ionicons name="checkmark-circle" size={12} color="white" /> <Ionicons name="checkmark-circle" size={12} color="white" />
<Text style={styles.statusBadgeText}>Enabled</Text> <Text style={styles.statusBadgeText}>{t('plugins.enabled')}</Text>
</View> </View>
)} )}
{switchingRepository === repo.id && ( {switchingRepository === repo.id && (
<View style={[styles.statusBadge, { backgroundColor: colors.primary }]}> <View style={[styles.statusBadge, { backgroundColor: colors.primary }]}>
<ActivityIndicator size={12} color="white" /> <ActivityIndicator size={12} color="white" />
<Text style={styles.statusBadgeText}>Updating...</Text> <Text style={styles.statusBadgeText}>{t('plugins.updating')}</Text>
</View> </View>
)} )}
</View> </View>
@ -1575,7 +1577,7 @@ const PluginsScreen: React.FC = () => {
{isRefreshing ? ( {isRefreshing ? (
<ActivityIndicator size="small" color={colors.mediumGray} /> <ActivityIndicator size="small" color={colors.mediumGray} />
) : ( ) : (
<Text style={styles.repositoryActionButtonText}>Refresh</Text> <Text style={styles.repositoryActionButtonText}>{t('plugins.refresh')}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@ -1583,7 +1585,7 @@ const PluginsScreen: React.FC = () => {
onPress={() => handleRemoveRepository(repo.id)} onPress={() => handleRemoveRepository(repo.id)}
disabled={switchingRepository !== null} disabled={switchingRepository !== null}
> >
<Text style={styles.repositoryActionButtonText}>Remove</Text> <Text style={styles.repositoryActionButtonText}>{t('plugins.remove')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@ -1598,13 +1600,13 @@ const PluginsScreen: React.FC = () => {
onPress={() => setShowAddRepositoryModal(true)} onPress={() => setShowAddRepositoryModal(true)}
disabled={!settings.enableLocalScrapers || switchingRepository !== null} disabled={!settings.enableLocalScrapers || switchingRepository !== null}
> >
<Text style={styles.buttonText}>Add New Repository</Text> <Text style={styles.buttonText}>{t('plugins.add_new_repo')}</Text>
</TouchableOpacity> </TouchableOpacity>
</CollapsibleSection> </CollapsibleSection>
{/* Available Plugins */} {/* Available Plugins */}
<CollapsibleSection <CollapsibleSection
title={`Available Plugins (${filteredPlugins.length})`} title={t('plugins.available_plugins', { count: filteredPlugins.length })}
isExpanded={expandedSections.plugins} isExpanded={expandedSections.plugins}
onToggle={() => toggleSection('plugins')} onToggle={() => toggleSection('plugins')}
colors={colors} colors={colors}
@ -1619,7 +1621,7 @@ const PluginsScreen: React.FC = () => {
style={styles.searchInput} style={styles.searchInput}
value={searchQuery} value={searchQuery}
onChangeText={setSearchQuery} onChangeText={setSearchQuery}
placeholder="Search plugins..." placeholder={t('plugins.search_placeholder')}
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
/> />
{searchQuery.length > 0 && ( {searchQuery.length > 0 && (
@ -1649,7 +1651,7 @@ const PluginsScreen: React.FC = () => {
styles.repositoryTabText, styles.repositoryTabText,
selectedRepositoryTab === 'all' && styles.repositoryTabTextSelected selectedRepositoryTab === 'all' && styles.repositoryTabTextSelected
]}> ]}>
All {t('plugins.all')}
</Text> </Text>
<Text style={[ <Text style={[
styles.repositoryTabCount, styles.repositoryTabCount,
@ -1708,7 +1710,7 @@ const PluginsScreen: React.FC = () => {
styles.filterChipText, styles.filterChipText,
selectedFilter === filter && styles.filterChipTextSelected selectedFilter === filter && styles.filterChipTextSelected
]}> ]}>
{filter === 'all' ? 'All Types' : filter === 'movie' ? 'Movies' : 'TV Shows'} {filter === 'all' ? t('plugins.filter_all') : filter === 'movie' ? t('plugins.filter_movies') : t('plugins.filter_tv')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
@ -1722,14 +1724,14 @@ const PluginsScreen: React.FC = () => {
onPress={() => handleBulkToggle(true)} onPress={() => handleBulkToggle(true)}
disabled={isRefreshing} disabled={isRefreshing}
> >
<Text style={[styles.bulkActionButtonText, { color: '#34C759' }]}>Enable All</Text> <Text style={[styles.bulkActionButtonText, { color: '#34C759' }]}>{t('plugins.enable_all')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.bulkActionButton, styles.bulkActionButtonDisabled]} style={[styles.bulkActionButton, styles.bulkActionButtonDisabled]}
onPress={() => handleBulkToggle(false)} onPress={() => handleBulkToggle(false)}
disabled={isRefreshing} disabled={isRefreshing}
> >
<Text style={[styles.bulkActionButtonText, { color: colors.mediumGray }]}>Disable All</Text> <Text style={[styles.bulkActionButtonText, { color: colors.mediumGray }]}>{t('plugins.disable_all')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
@ -1745,12 +1747,12 @@ const PluginsScreen: React.FC = () => {
style={styles.emptyStateIcon} style={styles.emptyStateIcon}
/> />
<Text style={styles.emptyStateTitle}> <Text style={styles.emptyStateTitle}>
{searchQuery ? 'No Plugins Found' : 'No Plugins Available'} {searchQuery ? t('plugins.no_plugins_found') : t('plugins.no_plugins_available')}
</Text> </Text>
<Text style={styles.emptyStateDescription}> <Text style={styles.emptyStateDescription}>
{searchQuery {searchQuery
? `No plugins match "${searchQuery}". Try a different search term.` ? t('plugins.no_match_desc', { query: searchQuery })
: 'Configure a repository above to view available plugins.' : t('plugins.configure_repo_desc')
} }
</Text> </Text>
{searchQuery && ( {searchQuery && (
@ -1758,7 +1760,7 @@ const PluginsScreen: React.FC = () => {
style={[styles.button, styles.secondaryButton]} style={[styles.button, styles.secondaryButton]}
onPress={() => setSearchQuery('')} onPress={() => setSearchQuery('')}
> >
<Text style={styles.secondaryButtonText}>Clear Search</Text> <Text style={styles.secondaryButtonText}>{t('plugins.clear_search')}</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
@ -1823,7 +1825,7 @@ const PluginsScreen: React.FC = () => {
<View style={styles.pluginCardMetaItem}> <View style={styles.pluginCardMetaItem}>
<Ionicons name="play-circle" size={12} color={colors.mediumGray} /> <Ionicons name="play-circle" size={12} color={colors.mediumGray} />
<Text style={styles.pluginCardMetaText}> <Text style={styles.pluginCardMetaText}>
No external player {t('plugins.no_external_player')}
</Text> </Text>
</View> </View>
)} )}
@ -1840,13 +1842,13 @@ const PluginsScreen: React.FC = () => {
{/* ShowBox Settings - only visible when ShowBox plugin is available */} {/* ShowBox Settings - only visible when ShowBox plugin is available */}
{showboxScraperId && plugin.id === showboxScraperId && settings.enableLocalScrapers && ( {showboxScraperId && plugin.id === showboxScraperId && settings.enableLocalScrapers && (
<View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}> <View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text> <Text style={[styles.settingTitle, { marginBottom: 8 }]}>{t('plugins.showbox_token')}</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
<TextInput <TextInput
style={[styles.textInput, { flex: 1, marginBottom: 0 }]} style={[styles.textInput, { flex: 1, marginBottom: 0 }]}
value={showboxUiToken} value={showboxUiToken}
onChangeText={setShowboxUiToken} onChangeText={setShowboxUiToken}
placeholder="Paste your ShowBox UI token" placeholder={t('plugins.showbox_placeholder')}
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
@ -1872,7 +1874,7 @@ const PluginsScreen: React.FC = () => {
openAlert('Saved', 'ShowBox settings updated'); openAlert('Saved', 'ShowBox settings updated');
}} }}
> >
<Text style={styles.buttonText}>Save</Text> <Text style={styles.buttonText}>{t('plugins.save')}</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
<TouchableOpacity <TouchableOpacity
@ -1885,7 +1887,7 @@ const PluginsScreen: React.FC = () => {
} }
}} }}
> >
<Text style={styles.secondaryButtonText}>Clear</Text> <Text style={styles.secondaryButtonText}>{t('plugins.clear')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@ -1898,7 +1900,7 @@ const PluginsScreen: React.FC = () => {
{/* Additional Settings */} {/* Additional Settings */}
<CollapsibleSection <CollapsibleSection
title="Additional Settings" title={t('plugins.additional_settings')}
isExpanded={expandedSections.settings} isExpanded={expandedSections.settings}
onToggle={() => toggleSection('settings')} onToggle={() => toggleSection('settings')}
colors={colors} colors={colors}
@ -1906,9 +1908,9 @@ const PluginsScreen: React.FC = () => {
> >
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Enable URL Validation</Text> <Text style={styles.settingTitle}>{t('plugins.enable_url_validation')}</Text>
<Text style={styles.settingDescription}> <Text style={styles.settingDescription}>
Validate streaming URLs before returning them (may slow down results but improves reliability) {t('plugins.url_validation_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -1922,9 +1924,9 @@ const PluginsScreen: React.FC = () => {
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Group Plugin Streams</Text> <Text style={styles.settingTitle}>{t('plugins.group_streams')}</Text>
<Text style={styles.settingDescription}> <Text style={styles.settingDescription}>
When enabled, plugin streams are grouped by repository. When disabled, each plugin shows as a separate provider. {t('plugins.group_streams_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -1944,9 +1946,9 @@ const PluginsScreen: React.FC = () => {
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Sort by Quality First</Text> <Text style={styles.settingTitle}>{t('plugins.sort_quality')}</Text>
<Text style={styles.settingDescription}> <Text style={styles.settingDescription}>
When enabled, streams are sorted by quality first, then by plugin. When disabled, streams are sorted by plugin first, then by quality. Only available when grouping is enabled. {t('plugins.sort_quality_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -1960,9 +1962,9 @@ const PluginsScreen: React.FC = () => {
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Show Plugin Logos</Text> <Text style={styles.settingTitle}>{t('plugins.show_logos')}</Text>
<Text style={styles.settingDescription}> <Text style={styles.settingDescription}>
Display plugin logos next to streaming links on the streams screen. {t('plugins.show_logos_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -1977,14 +1979,14 @@ const PluginsScreen: React.FC = () => {
{/* Quality Filtering */} {/* Quality Filtering */}
<CollapsibleSection <CollapsibleSection
title="Quality Filtering" title={t('plugins.quality_filtering')}
isExpanded={expandedSections.quality} isExpanded={expandedSections.quality}
onToggle={() => toggleSection('quality')} onToggle={() => toggleSection('quality')}
colors={colors} colors={colors}
styles={styles} styles={styles}
> >
<Text style={styles.sectionDescription}> <Text style={styles.sectionDescription}>
Exclude specific video qualities from search results. Tap on a quality to exclude it from plugin results. {t('plugins.quality_filtering_desc')}
</Text> </Text>
<View style={styles.qualityChipsContainer}> <View style={styles.qualityChipsContainer}>
@ -2015,25 +2017,25 @@ const PluginsScreen: React.FC = () => {
{(settings.excludedQualities || []).length > 0 && ( {(settings.excludedQualities || []).length > 0 && (
<Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}> <Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}>
Excluded qualities: {(settings.excludedQualities || []).join(', ')} {t('plugins.excluded_qualities')} {(settings.excludedQualities || []).join(', ')}
</Text> </Text>
)} )}
</CollapsibleSection> </CollapsibleSection>
{/* Language Filtering */} {/* Language Filtering */}
<CollapsibleSection <CollapsibleSection
title="Language Filtering" title={t('plugins.language_filtering')}
isExpanded={expandedSections.quality} isExpanded={expandedSections.quality}
onToggle={() => toggleSection('quality')} onToggle={() => toggleSection('quality')}
colors={colors} colors={colors}
styles={styles} styles={styles}
> >
<Text style={styles.sectionDescription}> <Text style={styles.sectionDescription}>
Exclude specific languages from search results. Tap on a language to exclude it from plugin results. {t('plugins.language_filtering_desc')}
</Text> </Text>
<Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}> <Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}>
<Text style={{ fontWeight: '600' }}>Note:</Text> This filter only applies to providers that include language information in their stream names. It does not affect other providers. <Text style={{ fontWeight: '600' }}>{t('plugins.note')}</Text> {t('plugins.language_filtering_note')}
</Text> </Text>
<View style={styles.qualityChipsContainer}> <View style={styles.qualityChipsContainer}>
@ -2064,21 +2066,20 @@ const PluginsScreen: React.FC = () => {
{(settings.excludedLanguages || []).length > 0 && ( {(settings.excludedLanguages || []).length > 0 && (
<Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}> <Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}>
Excluded languages: {(settings.excludedLanguages || []).join(', ')} {t('plugins.excluded_languages')} {(settings.excludedLanguages || []).join(', ')}
</Text> </Text>
)} )}
</CollapsibleSection> </CollapsibleSection>
{/* About */} {/* About */}
<View style={[styles.section, styles.lastSection]}> <View style={[styles.section, styles.lastSection]}>
<Text style={styles.sectionTitle}>About Plugins</Text> <Text style={styles.sectionTitle}>{t('plugins.about_title')}</Text>
<Text style={styles.infoText}> <Text style={styles.infoText}>
Plugins are JavaScript modules that can search for streaming links from various sources. {t('plugins.about_desc_1')}
They run locally on your device and can be installed from trusted repositories.
</Text> </Text>
<Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}> <Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}>
<Text style={{ fontWeight: '600' }}>Note:</Text> Providers marked as "Limited" depend on external APIs that may stop working without notice. <Text style={{ fontWeight: '600' }}>{t('plugins.note')}</Text> {t('plugins.about_desc_2')}
</Text> </Text>
</View> </View>
</ScrollView> </ScrollView>
@ -2093,24 +2094,24 @@ const PluginsScreen: React.FC = () => {
> >
<View style={styles.modalOverlay}> <View style={styles.modalOverlay}>
<View style={styles.modalContent}> <View style={styles.modalContent}>
<Text style={styles.modalTitle}>Getting Started with Plugins</Text> <Text style={styles.modalTitle}>{t('plugins.help_title')}</Text>
<Text style={styles.modalText}> <Text style={styles.modalText}>
1. <Text style={{ fontWeight: '600' }}>Enable Plugins</Text> - Turn on the main switch to allow plugins <Text>{t('plugins.help_step_1')}</Text>
</Text> </Text>
<Text style={styles.modalText}> <Text style={styles.modalText}>
2. <Text style={{ fontWeight: '600' }}>Add Repository</Text> - Add a GitHub raw URL or use the default repository <Text>{t('plugins.help_step_2')}</Text>
</Text> </Text>
<Text style={styles.modalText}> <Text style={styles.modalText}>
3. <Text style={{ fontWeight: '600' }}>Refresh Repository</Text> - Download available plugins from the repository <Text>{t('plugins.help_step_3')}</Text>
</Text> </Text>
<Text style={styles.modalText}> <Text style={styles.modalText}>
4. <Text style={{ fontWeight: '600' }}>Enable Plugins</Text> - Turn on the plugins you want to use for streaming <Text>{t('plugins.help_step_4')}</Text>
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.modalButton} style={styles.modalButton}
onPress={() => setShowHelpModal(false)} onPress={() => setShowHelpModal(false)}
> >
<Text style={styles.modalButtonText}>Got it!</Text> <Text style={styles.modalButtonText}>{t('plugins.got_it')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@ -2148,7 +2149,7 @@ const PluginsScreen: React.FC = () => {
{/* Format Hint */} {/* Format Hint */}
<Text style={styles.formatHint}> <Text style={styles.formatHint}>
Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch {t('plugins.repo_format_hint')}
</Text> </Text>
{/* Action Buttons */} {/* Action Buttons */}
@ -2160,7 +2161,7 @@ const PluginsScreen: React.FC = () => {
setNewRepositoryUrl(''); setNewRepositoryUrl('');
}} }}
> >
<Text style={styles.cancelButtonText}>Cancel</Text> <Text style={styles.cancelButtonText}>{t('plugins.cancel')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@ -2171,7 +2172,7 @@ const PluginsScreen: React.FC = () => {
{isLoading ? ( {isLoading ? (
<ActivityIndicator size="small" color={colors.white} /> <ActivityIndicator size="small" color={colors.white} />
) : ( ) : (
<Text style={styles.addButtonText}>Add</Text> <Text style={styles.addButtonText}>{t('plugins.add')}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,10 @@ import {
Platform, Platform,
Dimensions, Dimensions,
Linking, Linking,
FlatList,
} from 'react-native'; } from 'react-native';
import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
import { useTranslation } from 'react-i18next';
import { mmkvStorage } from '../services/mmkvStorage'; import { mmkvStorage } from '../services/mmkvStorage';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
@ -47,25 +50,15 @@ const { width } = Dimensions.get('window');
const isTablet = width >= 768; const isTablet = width >= 768;
// Settings categories for tablet sidebar // Settings categories for tablet sidebar
const SETTINGS_CATEGORIES = [ // Settings categories moved inside component for translation
{ id: 'account', title: 'Account', icon: 'user' as string },
{ id: 'content', title: 'Content & Discovery', icon: 'compass' as string },
{ id: 'appearance', title: 'Appearance', icon: 'sliders' as string },
{ id: 'integrations', title: 'Integrations', icon: 'layers' as string },
{ id: 'playback', title: 'Playback', icon: 'play-circle' as string },
{ id: 'backup', title: 'Backup & Restore', icon: 'archive' as string },
{ id: 'updates', title: 'Updates', icon: 'refresh-ccw' as string },
{ id: 'about', title: 'About', icon: 'info' as string },
{ id: 'developer', title: 'Developer', icon: 'code' as string },
{ id: 'cache', title: 'Cache', icon: 'database' as string },
];
// Tablet Sidebar Component // Tablet Sidebar Component
interface SidebarProps { interface SidebarProps {
selectedCategory: string; selectedCategory: string;
onCategorySelect: (category: string) => void; onCategorySelect: (category: string) => void;
currentTheme: any; currentTheme: any;
categories: typeof SETTINGS_CATEGORIES; categories: any[];
extraTopPadding?: number; extraTopPadding?: number;
} }
@ -140,10 +133,39 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, c
); );
}; };
const SettingsScreen: React.FC = () => { const SettingsScreen: React.FC = () => {
const { t, i18n } = useTranslation();
const SETTINGS_CATEGORIES = [
{ id: 'account', title: t('settings.account'), icon: 'user' },
{ id: 'content', title: t('settings.content_discovery'), icon: 'compass' },
{ id: 'appearance', title: t('settings.appearance'), icon: 'sliders' },
{ id: 'integrations', title: t('settings.integrations'), icon: 'layers' },
{ id: 'playback', title: t('settings.playback'), icon: 'play-circle' },
{ id: 'backup', title: t('settings.backup_restore'), icon: 'archive' },
{ id: 'updates', title: t('settings.updates'), icon: 'refresh-ccw' },
{ id: 'about', title: t('settings.about'), icon: 'info' },
{ id: 'developer', title: t('settings.developer'), icon: 'code' },
{ id: 'cache', title: t('settings.cache'), icon: 'database' },
];
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const [hasUpdateBadge, setHasUpdateBadge] = useState(false); const [hasUpdateBadge, setHasUpdateBadge] = useState(false);
const languageSheetRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets();
// Render backdrop for bottom sheet
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.6}
/>
),
[]
);
// CustomAlert state // CustomAlert state
const [alertVisible, setAlertVisible] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState(''); const [alertTitle, setAlertTitle] = useState('');
@ -177,7 +199,6 @@ const SettingsScreen: React.FC = () => {
const { lastUpdate } = useCatalogContext(); const { lastUpdate } = useCatalogContext();
const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext(); const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
// Tablet-specific state // Tablet-specific state
const [selectedCategory, setSelectedCategory] = useState('account'); const [selectedCategory, setSelectedCategory] = useState('account');
@ -328,11 +349,11 @@ const SettingsScreen: React.FC = () => {
switch (categoryId) { switch (categoryId) {
case 'account': case 'account':
return ( return (
<SettingsCard title="ACCOUNT" isTablet={isTablet}> <SettingsCard title={t('settings.sections.account')} isTablet={isTablet}>
{isItemVisible('trakt') && ( {isItemVisible('trakt') && (
<SettingItem <SettingItem
title="Trakt" title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : "Sign in to sync"} description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />} customIcon={<TraktIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')} onPress={() => navigation.navigate('TraktSettings')}
@ -360,16 +381,16 @@ const SettingsScreen: React.FC = () => {
case 'developer': case 'developer':
return __DEV__ ? ( return __DEV__ ? (
<SettingsCard title="DEVELOPER" isTablet={isTablet}> <SettingsCard title={t('settings.sections.testing')} isTablet={isTablet}>
<SettingItem <SettingItem
title="Test Onboarding" title={t('settings.items.test_onboarding')}
icon="play-circle" icon="play-circle"
onPress={() => navigation.navigate('Onboarding')} onPress={() => navigation.navigate('Onboarding')}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
title="Reset Onboarding" title={t('settings.items.reset_onboarding')}
icon="refresh-ccw" icon="refresh-ccw"
onPress={async () => { onPress={async () => {
try { try {
@ -383,9 +404,9 @@ const SettingsScreen: React.FC = () => {
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
title="Test Announcement" title={t('settings.items.test_announcement')}
icon="bell" icon="bell"
description="Show what's new overlay" description={t('settings.items.test_announcement_desc')}
onPress={async () => { onPress={async () => {
try { try {
await mmkvStorage.removeItem('announcement_v1.0.0_shown'); await mmkvStorage.removeItem('announcement_v1.0.0_shown');
@ -398,8 +419,8 @@ const SettingsScreen: React.FC = () => {
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
title="Reset Campaigns" title={t('settings.items.reset_campaigns')}
description="Clear campaign impressions" description={t('settings.items.reset_campaigns_desc')}
icon="refresh-cw" icon="refresh-cw"
onPress={async () => { onPress={async () => {
await campaignService.resetCampaigns(); await campaignService.resetCampaigns();
@ -409,12 +430,12 @@ const SettingsScreen: React.FC = () => {
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
title="Clear All Data" title={t('settings.items.clear_all_data')}
icon="trash-2" icon="trash-2"
onPress={() => { onPress={() => {
openAlert( openAlert(
'Clear All Data', t('settings.clear_data'),
'This will reset all settings and clear all cached data. Are you sure?', t('settings.clear_data_desc'),
[ [
{ label: 'Cancel', onPress: () => { } }, { label: 'Cancel', onPress: () => { } },
{ {
@ -439,9 +460,9 @@ const SettingsScreen: React.FC = () => {
case 'cache': case 'cache':
return mdblistKeySet ? ( return mdblistKeySet ? (
<SettingsCard title="CACHE MANAGEMENT" isTablet={isTablet}> <SettingsCard title={t('settings.sections.cache_management')} isTablet={isTablet}>
<SettingItem <SettingItem
title="Clear MDBList Cache" title={t('settings.clear_mdblist_cache')}
icon="database" icon="database"
onPress={handleClearMDBListCache} onPress={handleClearMDBListCache}
isLast={true} isLast={true}
@ -452,9 +473,9 @@ const SettingsScreen: React.FC = () => {
case 'backup': case 'backup':
return ( return (
<SettingsCard title="BACKUP & RESTORE" isTablet={isTablet}> <SettingsCard title={t('settings.backup_restore').toUpperCase()} isTablet={isTablet}>
<SettingItem <SettingItem
title="Backup & Restore" title={t('settings.backup_restore')}
description="Create and restore app backups" description="Create and restore app backups"
icon="archive" icon="archive"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -467,10 +488,10 @@ const SettingsScreen: React.FC = () => {
case 'updates': case 'updates':
return ( return (
<SettingsCard title="UPDATES" isTablet={isTablet}> <SettingsCard title={t('settings.updates').toUpperCase()} isTablet={isTablet}>
<SettingItem <SettingItem
title="App Updates" title={t('settings.app_updates')}
description="Check for updates and manage app version" description={t('settings.check_updates')}
icon="refresh-ccw" icon="refresh-ccw"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined} badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined}
@ -544,7 +565,7 @@ const SettingsScreen: React.FC = () => {
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle={'light-content'} /> <StatusBar barStyle={'light-content'} />
<ScreenHeader title="Settings" /> <ScreenHeader title={t('settings.settings_title')} />
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
<ScrollView <ScrollView
@ -555,11 +576,11 @@ const SettingsScreen: React.FC = () => {
> >
{/* Account */} {/* Account */}
{(settingsConfig?.categories?.['account']?.visible !== false) && isItemVisible('trakt') && ( {(settingsConfig?.categories?.['account']?.visible !== false) && isItemVisible('trakt') && (
<SettingsCard title="ACCOUNT"> <SettingsCard title={t('settings.account').toUpperCase()}>
{isItemVisible('trakt') && ( {isItemVisible('trakt') && (
<SettingItem <SettingItem
title="Trakt" title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : "Sign in to sync"} description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={20} color={currentTheme.colors.primary} />} customIcon={<TraktIcon size={20} color={currentTheme.colors.primary} />}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')} onPress={() => navigation.navigate('TraktSettings')}
@ -577,10 +598,23 @@ const SettingsScreen: React.FC = () => {
(settingsConfig?.categories?.['playback']?.visible !== false) (settingsConfig?.categories?.['playback']?.visible !== false)
) && ( ) && (
<SettingsCard title="GENERAL"> <SettingsCard title="GENERAL">
<SettingItem
title={t('settings.language')}
description={
i18n.language === 'pt' ? t('settings.portuguese') :
i18n.language === 'ar' ? t('settings.arabic') :
i18n.language === 'es' ? t('settings.spanish') :
i18n.language === 'fr' ? t('settings.french') :
t('settings.english')
}
icon="globe"
renderControl={() => <ChevronRight />}
onPress={() => languageSheetRef.current?.present()}
/>
{(settingsConfig?.categories?.['content']?.visible !== false) && ( {(settingsConfig?.categories?.['content']?.visible !== false) && (
<SettingItem <SettingItem
title="Content & Discovery" title={t('settings.content_discovery')}
description="Addons, catalogs, and sources" description={t('settings.add_catalogs_sources')}
icon="compass" icon="compass"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('ContentDiscoverySettings')} onPress={() => navigation.navigate('ContentDiscoverySettings')}
@ -588,7 +622,7 @@ const SettingsScreen: React.FC = () => {
)} )}
{(settingsConfig?.categories?.['appearance']?.visible !== false) && ( {(settingsConfig?.categories?.['appearance']?.visible !== false) && (
<SettingItem <SettingItem
title="Appearance" title={t('settings.appearance')}
description={currentTheme.name} description={currentTheme.name}
icon="sliders" icon="sliders"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -597,8 +631,8 @@ const SettingsScreen: React.FC = () => {
)} )}
{(settingsConfig?.categories?.['integrations']?.visible !== false) && ( {(settingsConfig?.categories?.['integrations']?.visible !== false) && (
<SettingItem <SettingItem
title="Integrations" title={t('settings.integrations')}
description="MDBList, TMDB, AI" description={t('settings.mdblist_tmdb_ai')}
icon="layers" icon="layers"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('IntegrationsSettings')} onPress={() => navigation.navigate('IntegrationsSettings')}
@ -606,8 +640,8 @@ const SettingsScreen: React.FC = () => {
)} )}
{(settingsConfig?.categories?.['playback']?.visible !== false) && ( {(settingsConfig?.categories?.['playback']?.visible !== false) && (
<SettingItem <SettingItem
title="Playback" title={t('settings.playback')}
description="Player, trailers, downloads" description={t('settings.player_trailers_downloads')}
icon="play-circle" icon="play-circle"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('PlaybackSettings')} onPress={() => navigation.navigate('PlaybackSettings')}
@ -625,7 +659,7 @@ const SettingsScreen: React.FC = () => {
<SettingsCard title="DATA"> <SettingsCard title="DATA">
{(settingsConfig?.categories?.['backup']?.visible !== false) && ( {(settingsConfig?.categories?.['backup']?.visible !== false) && (
<SettingItem <SettingItem
title="Backup & Restore" title={t('settings.backup_restore')}
description="Create and restore app backups" description="Create and restore app backups"
icon="archive" icon="archive"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -634,8 +668,8 @@ const SettingsScreen: React.FC = () => {
)} )}
{(settingsConfig?.categories?.['updates']?.visible !== false) && ( {(settingsConfig?.categories?.['updates']?.visible !== false) && (
<SettingItem <SettingItem
title="App Updates" title={t('settings.app_updates')}
description="Check for updates" description={t('settings.check_updates')}
icon="refresh-ccw" icon="refresh-ccw"
badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined} badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -656,7 +690,7 @@ const SettingsScreen: React.FC = () => {
{mdblistKeySet && ( {mdblistKeySet && (
<SettingsCard title="CACHE"> <SettingsCard title="CACHE">
<SettingItem <SettingItem
title="Clear MDBList Cache" title={t('settings.clear_mdblist_cache')}
icon="database" icon="database"
onPress={handleClearMDBListCache} onPress={handleClearMDBListCache}
isLast isLast
@ -665,9 +699,9 @@ const SettingsScreen: React.FC = () => {
)} )}
{/* About */} {/* About */}
<SettingsCard title="ABOUT"> <SettingsCard title={t('settings.about').toUpperCase()}>
<SettingItem <SettingItem
title="About Nuvio" title={t('settings.about_nuvio')}
description={getDisplayedAppVersion()} description={getDisplayedAppVersion()}
icon="info" icon="info"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -678,10 +712,10 @@ const SettingsScreen: React.FC = () => {
{/* Developer - only in DEV mode */} {/* Developer - only in DEV mode */}
{__DEV__ && ( {__DEV__ && (
<SettingsCard title="DEVELOPER"> <SettingsCard title={t('settings.sections.testing')}>
<SettingItem <SettingItem
title="Developer Tools" title={t('settings.items.developer_tools')}
description="Testing and debug options" description={t('settings.developer_tools')}
icon="code" icon="code"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('DeveloperSettings')} onPress={() => navigation.navigate('DeveloperSettings')}
@ -697,7 +731,7 @@ const SettingsScreen: React.FC = () => {
{displayDownloads.toLocaleString()} {displayDownloads.toLocaleString()}
</Text> </Text>
<Text style={[styles.downloadsLabel, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.downloadsLabel, { color: currentTheme.colors.mediumEmphasis }]}>
downloads and counting {t('settings.downloads_counter')}
</Text> </Text>
</View> </View>
)} )}
@ -776,7 +810,7 @@ const SettingsScreen: React.FC = () => {
<View style={styles.footer}> <View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
Made with by Tapframe and friends {t('settings.made_with_love')}
</Text> </Text>
</View> </View>
@ -791,6 +825,148 @@ const SettingsScreen: React.FC = () => {
actions={alertActions} actions={alertActions}
onClose={() => setAlertVisible(false)} onClose={() => setAlertVisible(false)}
/> />
<BottomSheetModal
ref={languageSheetRef}
index={0}
snapPoints={['50%']}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
}}
handleIndicatorStyle={{
backgroundColor: currentTheme.colors.mediumGray,
width: 40,
}}
>
<BottomSheetView style={[styles.actionSheetContent, { paddingBottom: insets.bottom + 16 }]}>
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
{t('settings.select_language')}
</Text>
<TouchableOpacity onPress={() => languageSheetRef.current?.close()}>
<Feather name="x" size={24} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</View>
<ScrollView
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={styles.bottomSheetContent}
>
<TouchableOpacity
style={[
styles.languageOption,
i18n.language === 'en' && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => {
i18n.changeLanguage('en');
languageSheetRef.current?.close();
}}
>
<Text style={[
styles.languageText,
{ color: currentTheme.colors.highEmphasis },
i18n.language === 'en' && { color: currentTheme.colors.primary, fontWeight: 'bold' }
]}>
{t('settings.english')}
</Text>
{i18n.language === 'en' && (
<Feather name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.languageOption,
i18n.language === 'pt' && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => {
i18n.changeLanguage('pt');
languageSheetRef.current?.close();
}}
>
<Text style={[
styles.languageText,
{ color: currentTheme.colors.highEmphasis },
i18n.language === 'pt' && { color: currentTheme.colors.primary, fontWeight: 'bold' }
]}>
{t('settings.portuguese')}
</Text>
{i18n.language === 'pt' && (
<Feather name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.languageOption,
i18n.language === 'ar' && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => {
i18n.changeLanguage('ar');
languageSheetRef.current?.close();
}}
>
<Text style={[
styles.languageText,
{ color: currentTheme.colors.highEmphasis },
i18n.language === 'ar' && { color: currentTheme.colors.primary, fontWeight: 'bold' }
]}>
{t('settings.arabic')}
</Text>
{i18n.language === 'ar' && (
<Feather name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.languageOption,
i18n.language === 'es' && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => {
i18n.changeLanguage('es');
languageSheetRef.current?.close();
}}
>
<Text style={[
styles.languageText,
{ color: currentTheme.colors.highEmphasis },
i18n.language === 'es' && { color: currentTheme.colors.primary, fontWeight: 'bold' }
]}>
{t('settings.spanish')}
</Text>
{i18n.language === 'es' && (
<Feather name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
<TouchableOpacity
style={[
styles.languageOption,
i18n.language === 'fr' && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => {
i18n.changeLanguage('fr');
languageSheetRef.current?.close();
}}
>
<Text style={[
styles.languageText,
{ color: currentTheme.colors.highEmphasis },
i18n.language === 'fr' && { color: currentTheme.colors.primary, fontWeight: 'bold' }
]}>
{t('settings.french')}
</Text>
{i18n.language === 'fr' && (
<Feather name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
</ScrollView>
</BottomSheetView>
</BottomSheetModal>
</View> </View>
); );
}; };
@ -799,6 +975,39 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
actionSheetContent: {
flex: 1,
},
bottomSheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingVertical: 16,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
},
bottomSheetTitle: {
fontSize: 18,
fontWeight: '600',
},
bottomSheetContent: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 24,
},
languageOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
paddingHorizontal: 16,
borderRadius: 8,
marginBottom: 8,
},
languageText: {
fontSize: 16,
},
// Mobile styles // Mobile styles
contentContainer: { contentContainer: {
flex: 1, flex: 1,

View file

@ -28,6 +28,7 @@ import { logger } from '../utils/logger';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import CustomAlert from '../components/CustomAlert'; import CustomAlert from '../components/CustomAlert';
import { useTranslation } from 'react-i18next';
// (duplicate import removed) // (duplicate import removed)
const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key'; const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
@ -63,6 +64,7 @@ const EXAMPLE_SHOWS = [
]; ];
const TMDBSettingsScreen = () => { const TMDBSettingsScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation(); const navigation = useNavigation();
const [apiKey, setApiKey] = useState(''); const [apiKey, setApiKey] = useState('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -74,7 +76,7 @@ const TMDBSettingsScreen = () => {
const [alertTitle, setAlertTitle] = useState(''); const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState(''); const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void; style?: object }>>([ const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void; style?: object }>>([
{ label: 'OK', onPress: () => setAlertVisible(false) }, { label: t('common.ok'), onPress: () => setAlertVisible(false) },
]); ]);
const apiKeyInputRef = useRef<TextInput>(null); const apiKeyInputRef = useRef<TextInput>(null);
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
@ -108,7 +110,7 @@ const TMDBSettingsScreen = () => {
})) }))
); );
} else { } else {
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
} }
setAlertVisible(true); setAlertVisible(true);
}; };
@ -154,25 +156,25 @@ const TMDBSettingsScreen = () => {
const handleClearCache = () => { const handleClearCache = () => {
openAlert( openAlert(
'Clear TMDB Cache', t('tmdb_settings.clear_cache_title'),
`This will clear all cached TMDB data (${cacheSize}). This may temporarily slow down loading until cache rebuilds.`, t('tmdb_settings.clear_cache_msg', { size: cacheSize }),
[ [
{ {
label: 'Cancel', label: t('common.cancel'),
onPress: () => logger.log('[TMDBSettingsScreen] Clear cache cancelled'), onPress: () => logger.log('[TMDBSettingsScreen] Clear cache cancelled'),
}, },
{ {
label: 'Clear', label: t('tmdb_settings.clear_cache'),
onPress: async () => { onPress: async () => {
logger.log('[TMDBSettingsScreen] Proceeding with cache clear'); logger.log('[TMDBSettingsScreen] Proceeding with cache clear');
try { try {
await tmdbService.clearAllCache(); await tmdbService.clearAllCache();
setCacheSize('0 KB'); setCacheSize('0 KB');
logger.log('[TMDBSettingsScreen] Cache cleared successfully'); logger.log('[TMDBSettingsScreen] Cache cleared successfully');
openAlert('Success', 'TMDB cache cleared successfully.'); openAlert(t('common.success'), t('tmdb_settings.clear_cache_success'));
} catch (error) { } catch (error) {
logger.error('[TMDBSettingsScreen] Failed to clear cache:', error); logger.error('[TMDBSettingsScreen] Failed to clear cache:', error);
openAlert('Error', 'Failed to clear cache.'); openAlert(t('common.error'), t('tmdb_settings.clear_cache_error'));
} }
}, },
}, },
@ -217,7 +219,7 @@ const TMDBSettingsScreen = () => {
const trimmedKey = apiKey.trim(); const trimmedKey = apiKey.trim();
if (!trimmedKey) { if (!trimmedKey) {
logger.warn('[TMDBSettingsScreen] Empty API key provided'); logger.warn('[TMDBSettingsScreen] Empty API key provided');
setTestResult({ success: false, message: 'API Key cannot be empty.' }); setTestResult({ success: false, message: t('tmdb_settings.empty_api_key') });
return; return;
} }
@ -228,17 +230,17 @@ const TMDBSettingsScreen = () => {
await mmkvStorage.setItem(USE_CUSTOM_TMDB_API_KEY, 'true'); await mmkvStorage.setItem(USE_CUSTOM_TMDB_API_KEY, 'true');
setIsKeySet(true); setIsKeySet(true);
setUseCustomKey(true); setUseCustomKey(true);
setTestResult({ success: true, message: 'API key verified and saved successfully.' }); setTestResult({ success: true, message: t('tmdb_settings.key_verified') });
logger.log('[TMDBSettingsScreen] API key saved successfully'); logger.log('[TMDBSettingsScreen] API key saved successfully');
} else { } else {
logger.warn('[TMDBSettingsScreen] API key test failed'); logger.warn('[TMDBSettingsScreen] API key test failed');
setTestResult({ success: false, message: 'Invalid API key. Please check and try again.' }); setTestResult({ success: false, message: t('tmdb_settings.invalid_api_key') });
} }
} catch (error) { } catch (error) {
logger.error('[TMDBSettingsScreen] Error saving API key:', error); logger.error('[TMDBSettingsScreen] Error saving API key:', error);
setTestResult({ setTestResult({
success: false, success: false,
message: 'An error occurred while saving. Please try again.' message: t('tmdb_settings.save_error')
}); });
} }
}; };
@ -265,15 +267,15 @@ const TMDBSettingsScreen = () => {
const clearApiKey = async () => { const clearApiKey = async () => {
logger.log('[TMDBSettingsScreen] Clear API key requested'); logger.log('[TMDBSettingsScreen] Clear API key requested');
openAlert( openAlert(
'Clear API Key', t('tmdb_settings.clear_api_key_title'),
'Are you sure you want to remove your custom API key and revert to the default?', t('tmdb_settings.clear_api_key_msg'),
[ [
{ {
label: 'Cancel', label: t('common.cancel'),
onPress: () => logger.log('[TMDBSettingsScreen] Clear API key cancelled'), onPress: () => logger.log('[TMDBSettingsScreen] Clear API key cancelled'),
}, },
{ {
label: 'Clear', label: t('mdblist.clear'),
onPress: async () => { onPress: async () => {
logger.log('[TMDBSettingsScreen] Proceeding with API key clear'); logger.log('[TMDBSettingsScreen] Proceeding with API key clear');
try { try {
@ -286,7 +288,7 @@ const TMDBSettingsScreen = () => {
logger.log('[TMDBSettingsScreen] API key cleared successfully'); logger.log('[TMDBSettingsScreen] API key cleared successfully');
} catch (error) { } catch (error) {
logger.error('[TMDBSettingsScreen] Failed to clear API key:', error); logger.error('[TMDBSettingsScreen] Failed to clear API key:', error);
openAlert('Error', 'Failed to clear API key'); openAlert(t('common.error'), t('tmdb_settings.clear_api_key_error'));
} }
}, },
}, },
@ -305,21 +307,21 @@ const TMDBSettingsScreen = () => {
logger.log('[TMDBSettingsScreen] Switching to built-in API key'); logger.log('[TMDBSettingsScreen] Switching to built-in API key');
setTestResult({ setTestResult({
success: true, success: true,
message: 'Now using the built-in TMDb API key.' message: t('tmdb_settings.using_builtin_key')
}); });
} else if (apiKey && isKeySet) { } else if (apiKey && isKeySet) {
// If switching to custom key and we have a key // If switching to custom key and we have a key
logger.log('[TMDBSettingsScreen] Switching to custom API key'); logger.log('[TMDBSettingsScreen] Switching to custom API key');
setTestResult({ setTestResult({
success: true, success: true,
message: 'Now using your custom TMDb API key.' message: t('tmdb_settings.using_custom_key')
}); });
} else { } else {
// If switching to custom key but don't have a key yet // If switching to custom key but don't have a key yet
logger.log('[TMDBSettingsScreen] No custom key available yet'); logger.log('[TMDBSettingsScreen] No custom key available yet');
setTestResult({ setTestResult({
success: false, success: false,
message: 'Please enter and save your custom TMDb API key.' message: t('tmdb_settings.enter_custom_key')
}); });
} }
} catch (error) { } catch (error) {
@ -462,7 +464,7 @@ const TMDBSettingsScreen = () => {
)} )}
{!logo && ( {!logo && (
<View style={styles.noLogoContainer}> <View style={styles.noLogoContainer}>
<Text style={styles.noLogoText}>No logo available</Text> <Text style={styles.noLogoText}>{t('tmdb_settings.no_logo')}</Text>
</View> </View>
)} )}
</View> </View>
@ -505,7 +507,7 @@ const TMDBSettingsScreen = () => {
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} /> <ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>Loading Settings...</Text> <Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>{t('common.loading')}</Text>
</View> </View>
</View> </View>
); );
@ -521,11 +523,11 @@ const TMDBSettingsScreen = () => {
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} /> <MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text> <Text style={[styles.backText, { color: currentTheme.colors.primary }]}>{t('settings.settings_title')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}> <Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
TMDb Settings {t('tmdb_settings.title')}
</Text> </Text>
</View> </View>
@ -539,17 +541,17 @@ const TMDBSettingsScreen = () => {
<View style={[styles.sectionCard, { backgroundColor: currentTheme.colors.elevation2 }]}> <View style={[styles.sectionCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} /> <MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Metadata Enrichment</Text> <Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.metadata_enrichment')}</Text>
</View> </View>
<Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.sectionDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Enhance your content metadata with TMDb data for better details and information. {t('tmdb_settings.metadata_enrichment_desc')}
</Text> </Text>
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Enable Enrichment</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.enable_enrichment')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback. {t('tmdb_settings.enable_enrichment_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -567,9 +569,9 @@ const TMDBSettingsScreen = () => {
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Localized Text</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.localized_text')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Fetch titles and descriptions in your preferred language from TMDb. {t('tmdb_settings.localized_text_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -587,7 +589,7 @@ const TMDBSettingsScreen = () => {
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Language</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.language')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Current: {(settings.tmdbLanguagePreference || 'en').toUpperCase()} Current: {(settings.tmdbLanguagePreference || 'en').toUpperCase()}
</Text> </Text>
@ -596,20 +598,20 @@ const TMDBSettingsScreen = () => {
onPress={() => setLanguagePickerVisible(true)} onPress={() => setLanguagePickerVisible(true)}
style={[styles.languageButton, { backgroundColor: currentTheme.colors.primary }]} style={[styles.languageButton, { backgroundColor: currentTheme.colors.primary }]}
> >
<Text style={[styles.languageButtonText, { color: currentTheme.colors.white }]}>Change</Text> <Text style={[styles.languageButtonText, { color: currentTheme.colors.white }]}>{t('tmdb_settings.change')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Logo Preview */} {/* Logo Preview */}
<View style={styles.divider} /> <View style={styles.divider} />
<Text style={[styles.settingTitle, { color: currentTheme.colors.text, marginBottom: 8 }]}>Logo Preview</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text, marginBottom: 8 }]}>{t('tmdb_settings.logo_preview')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis, marginBottom: 12 }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis, marginBottom: 12 }]}>
Preview shows how localized logos will appear in the selected language. {t('tmdb_settings.logo_preview_desc')}
</Text> </Text>
{/* Show selector */} {/* Show selector */}
<Text style={[styles.selectorLabel, { color: currentTheme.colors.mediumEmphasis }]}>Example:</Text> <Text style={[styles.selectorLabel, { color: currentTheme.colors.mediumEmphasis }]}>{t('tmdb_settings.example')}</Text>
<ScrollView <ScrollView
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
@ -655,17 +657,17 @@ const TMDBSettingsScreen = () => {
{/* Granular Enrichment Options */} {/* Granular Enrichment Options */}
<View style={styles.divider} /> <View style={styles.divider} />
<Text style={[styles.settingTitle, { color: currentTheme.colors.text, marginBottom: 4 }]}>Enrichment Options</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text, marginBottom: 4 }]}>{t('tmdb_settings.enrichment_options')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis, marginBottom: 16 }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis, marginBottom: 16 }]}>
Control which data is fetched from TMDb. Disabled options will use addon data if available. {t('tmdb_settings.enrichment_options_desc')}
</Text> </Text>
{/* Cast & Crew */} {/* Cast & Crew */}
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Cast & Crew</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.cast_crew')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Actors, directors, writers with profile photos {t('tmdb_settings.cast_crew_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -680,9 +682,9 @@ const TMDBSettingsScreen = () => {
{/* Title & Description */} {/* Title & Description */}
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Title & Description</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.title_description')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Use TMDb localized title and overview text {t('tmdb_settings.title_description_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -697,9 +699,9 @@ const TMDBSettingsScreen = () => {
{/* Title Logos */} {/* Title Logos */}
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Title Logos</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.title_logos')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
High-quality title treatment images {t('tmdb_settings.title_logos_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -714,9 +716,9 @@ const TMDBSettingsScreen = () => {
{/* Banners/Backdrops */} {/* Banners/Backdrops */}
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Banners & Backdrops</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.banners_backdrops')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
High-resolution backdrop images {t('tmdb_settings.banners_backdrops_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -731,9 +733,9 @@ const TMDBSettingsScreen = () => {
{/* Certification */} {/* Certification */}
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Content Certification</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.certification')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Age ratings (PG-13, R, TV-MA, etc.) {t('tmdb_settings.certification_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -748,9 +750,9 @@ const TMDBSettingsScreen = () => {
{/* Recommendations */} {/* Recommendations */}
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Recommendations</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.recommendations')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Similar content suggestions {t('tmdb_settings.recommendations_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -765,9 +767,9 @@ const TMDBSettingsScreen = () => {
{/* Episode Data */} {/* Episode Data */}
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Episode Data</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.episode_data')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Episode thumbnails, info & fallbacks for TV shows {t('tmdb_settings.episode_data_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -782,9 +784,9 @@ const TMDBSettingsScreen = () => {
{/* Season Posters */} {/* Season Posters */}
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingTextContainer}> <View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Season Posters</Text> <Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>{t('tmdb_settings.season_posters')}</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Season-specific poster images {t('tmdb_settings.season_posters_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch

View file

@ -25,6 +25,7 @@ import { useTraktIntegration } from '../hooks/useTraktIntegration';
import { useTraktAutosyncSettings } from '../hooks/useTraktAutosyncSettings'; import { useTraktAutosyncSettings } from '../hooks/useTraktAutosyncSettings';
import { colors } from '../styles'; import { colors } from '../styles';
import CustomAlert from '../components/CustomAlert'; import CustomAlert from '../components/CustomAlert';
import { useTranslation } from 'react-i18next';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -46,6 +47,7 @@ const redirectUri = makeRedirectUri({
}); });
const TraktSettingsScreen: React.FC = () => { const TraktSettingsScreen: React.FC = () => {
const { t } = useTranslation();
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const isDarkMode = settings.enableDarkMode; const isDarkMode = settings.enableDarkMode;
const navigation = useNavigation(); const navigation = useNavigation();
@ -72,7 +74,7 @@ const TraktSettingsScreen: React.FC = () => {
const [alertTitle, setAlertTitle] = useState(''); const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState(''); const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void; style?: object }>>([ const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void; style?: object }>>([
{ label: 'OK', onPress: () => setAlertVisible(false) }, { label: t('common.ok'), onPress: () => setAlertVisible(false) },
]); ]);
const openAlert = ( const openAlert = (
@ -91,7 +93,7 @@ const TraktSettingsScreen: React.FC = () => {
})) }))
); );
} else { } else {
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
} }
setAlertVisible(true); setAlertVisible(true);
}; };
@ -148,11 +150,11 @@ const TraktSettingsScreen: React.FC = () => {
checkAuthStatus().then(() => { checkAuthStatus().then(() => {
// Show success message // Show success message
openAlert( openAlert(
'Successfully Connected', t('trakt.auth_success_title'),
'Your Trakt account has been connected successfully.', t('trakt.auth_success_msg'),
[ [
{ {
label: 'OK', label: t('common.ok'),
onPress: () => navigation.goBack(), onPress: () => navigation.goBack(),
} }
] ]
@ -160,19 +162,19 @@ const TraktSettingsScreen: React.FC = () => {
}); });
} else { } else {
logger.error('[TraktSettingsScreen] Token exchange failed'); logger.error('[TraktSettingsScreen] Token exchange failed');
openAlert('Authentication Error', 'Failed to complete authentication with Trakt.'); openAlert(t('trakt.auth_error_title'), t('trakt.auth_error_msg'));
} }
}) })
.catch(error => { .catch(error => {
logger.error('[TraktSettingsScreen] Token exchange error:', error); logger.error('[TraktSettingsScreen] Token exchange error:', error);
openAlert('Authentication Error', 'An error occurred during authentication.'); openAlert(t('trakt.auth_error_title'), t('trakt.auth_error_generic'));
}) })
.finally(() => { .finally(() => {
setIsExchangingCode(false); setIsExchangingCode(false);
}); });
} else if (response.type === 'error') { } else if (response.type === 'error') {
logger.error('[TraktSettingsScreen] Authentication error:', response.error); logger.error('[TraktSettingsScreen] Authentication error:', response.error);
openAlert('Authentication Error', response.error?.message || 'An error occurred during authentication.'); openAlert(t('trakt.auth_error_title'), response.error?.message || t('trakt.auth_error_generic'));
setIsExchangingCode(false); setIsExchangingCode(false);
} else { } else {
logger.log('[TraktSettingsScreen] Auth response type:', response.type); logger.log('[TraktSettingsScreen] Auth response type:', response.type);
@ -187,12 +189,12 @@ const TraktSettingsScreen: React.FC = () => {
const handleSignOut = async () => { const handleSignOut = async () => {
openAlert( openAlert(
'Sign Out', t('trakt.sign_out'),
'Are you sure you want to sign out of your Trakt account?', t('trakt.sign_out_confirm'),
[ [
{ label: 'Cancel', onPress: () => { } }, { label: t('common.cancel'), onPress: () => { } },
{ {
label: 'Sign Out', label: t('trakt.sign_out'),
onPress: async () => { onPress: async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@ -203,7 +205,7 @@ const TraktSettingsScreen: React.FC = () => {
await refreshAuthStatus(); await refreshAuthStatus();
} catch (error) { } catch (error) {
logger.error('[TraktSettingsScreen] Error signing out:', error); logger.error('[TraktSettingsScreen] Error signing out:', error);
openAlert('Error', 'Failed to sign out of Trakt.'); openAlert(t('common.error'), t('trakt.sign_out_error'));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -230,7 +232,7 @@ const TraktSettingsScreen: React.FC = () => {
color={isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark} color={isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark}
/> />
<Text style={[styles.backText, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}> <Text style={[styles.backText, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
Settings {t('settings.title')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -240,7 +242,7 @@ const TraktSettingsScreen: React.FC = () => {
</View> </View>
<Text style={[styles.headerTitle, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}> <Text style={[styles.headerTitle, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
Trakt Settings {t('trakt.settings_title')}
</Text> </Text>
{/* Maintenance Mode Banner */} {/* Maintenance Mode Banner */}
@ -248,7 +250,7 @@ const TraktSettingsScreen: React.FC = () => {
<View style={styles.maintenanceBanner}> <View style={styles.maintenanceBanner}>
<MaterialIcons name="engineering" size={24} color="#FFF" /> <MaterialIcons name="engineering" size={24} color="#FFF" />
<View style={styles.maintenanceBannerTextContainer}> <View style={styles.maintenanceBannerTextContainer}>
<Text style={styles.maintenanceBannerTitle}>Under Maintenance</Text> <Text style={styles.maintenanceBannerTitle}>{t('trakt.maintenance_title')}</Text>
<Text style={styles.maintenanceBannerMessage}> <Text style={styles.maintenanceBannerMessage}>
{traktService.getMaintenanceMessage()} {traktService.getMaintenanceMessage()}
</Text> </Text>
@ -279,13 +281,13 @@ const TraktSettingsScreen: React.FC = () => {
styles.signInTitle, styles.signInTitle,
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark } { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}> ]}>
Trakt Unavailable {t('trakt.maintenance_unavailable')}
</Text> </Text>
<Text style={[ <Text style={[
styles.signInDescription, styles.signInDescription,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}> ]}>
The Trakt integration is temporarily paused for maintenance. All syncing and authentication is disabled until maintenance is complete. {t('trakt.maintenance_desc')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[
@ -296,7 +298,7 @@ const TraktSettingsScreen: React.FC = () => {
> >
<MaterialIcons name="engineering" size={20} color={currentTheme.colors.mediumEmphasis} style={{ marginRight: 8 }} /> <MaterialIcons name="engineering" size={20} color={currentTheme.colors.mediumEmphasis} style={{ marginRight: 8 }} />
<Text style={[styles.buttonText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.buttonText, { color: currentTheme.colors.mediumEmphasis }]}>
Service Under Maintenance {t('trakt.maintenance_button')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -343,7 +345,7 @@ const TraktSettingsScreen: React.FC = () => {
styles.joinedDate, styles.joinedDate,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}> ]}>
Joined {new Date(userProfile.joined_at).toLocaleDateString()} {t('trakt.joined', { date: new Date(userProfile.joined_at).toLocaleDateString() })}
</Text> </Text>
</View> </View>
@ -355,7 +357,7 @@ const TraktSettingsScreen: React.FC = () => {
]} ]}
onPress={handleSignOut} onPress={handleSignOut}
> >
<Text style={styles.buttonText}>Sign Out</Text> <Text style={styles.buttonText}>{t('trakt.sign_out')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : ( ) : (
@ -369,13 +371,13 @@ const TraktSettingsScreen: React.FC = () => {
styles.signInTitle, styles.signInTitle,
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark } { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
]}> ]}>
Connect with Trakt {t('trakt.connect_title')}
</Text> </Text>
<Text style={[ <Text style={[
styles.signInDescription, styles.signInDescription,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}> ]}>
Sync your watch history, watchlist, and collection with Trakt.tv {t('trakt.connect_desc')}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[
@ -389,7 +391,7 @@ const TraktSettingsScreen: React.FC = () => {
<ActivityIndicator size="small" color="white" /> <ActivityIndicator size="small" color="white" />
) : ( ) : (
<Text style={styles.buttonText}> <Text style={styles.buttonText}>
Sign In with Trakt {t('trakt.sign_in')}
</Text> </Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
@ -407,7 +409,7 @@ const TraktSettingsScreen: React.FC = () => {
styles.sectionTitle, styles.sectionTitle,
{ color: currentTheme.colors.highEmphasis } { color: currentTheme.colors.highEmphasis }
]}> ]}>
Sync Settings {t('trakt.sync_settings_title')}
</Text> </Text>
<View style={[ <View style={[
styles.infoBox, styles.infoBox,
@ -417,7 +419,7 @@ const TraktSettingsScreen: React.FC = () => {
styles.infoText, styles.infoText,
{ color: currentTheme.colors.mediumEmphasis } { color: currentTheme.colors.mediumEmphasis }
]}> ]}>
When connected to Trakt, full history is synced directly from the API and is not written to local storage. Your Continue Watching list reflects your global Trakt progress. {t('trakt.sync_info')}
</Text> </Text>
</View> </View>
<View style={styles.settingItem}> <View style={styles.settingItem}>
@ -427,13 +429,13 @@ const TraktSettingsScreen: React.FC = () => {
styles.settingLabel, styles.settingLabel,
{ color: currentTheme.colors.highEmphasis } { color: currentTheme.colors.highEmphasis }
]}> ]}>
Auto-sync playback progress {t('trakt.auto_sync_label')}
</Text> </Text>
<Text style={[ <Text style={[
styles.settingDescription, styles.settingDescription,
{ color: currentTheme.colors.mediumEmphasis } { color: currentTheme.colors.mediumEmphasis }
]}> ]}>
Automatically sync watch progress to Trakt {t('trakt.auto_sync_desc')}
</Text> </Text>
</View> </View>
<View style={styles.settingToggleContainer}> <View style={styles.settingToggleContainer}>
@ -456,13 +458,13 @@ const TraktSettingsScreen: React.FC = () => {
styles.settingLabel, styles.settingLabel,
{ color: currentTheme.colors.highEmphasis } { color: currentTheme.colors.highEmphasis }
]}> ]}>
Import watched history {t('trakt.import_history_label')}
</Text> </Text>
<Text style={[ <Text style={[
styles.settingDescription, styles.settingDescription,
{ color: currentTheme.colors.mediumEmphasis } { color: currentTheme.colors.mediumEmphasis }
]}> ]}>
Use "Sync Now" to import your watch history and progress from Trakt {t('trakt.import_history_desc')}
</Text> </Text>
</View> </View>
</View> </View>
@ -479,8 +481,8 @@ const TraktSettingsScreen: React.FC = () => {
onPress={async () => { onPress={async () => {
const success = await performManualSync(); const success = await performManualSync();
openAlert( openAlert(
'Sync Complete', t('trakt.sync_complete_title'),
success ? 'Successfully synced your watch progress with Trakt.' : 'Sync failed. Please try again.' success ? t('trakt.sync_success_msg') : t('trakt.sync_error_msg')
); );
}} }}
> >
@ -494,7 +496,7 @@ const TraktSettingsScreen: React.FC = () => {
styles.buttonText, styles.buttonText,
{ color: currentTheme.colors.primary } { color: currentTheme.colors.primary }
]}> ]}>
Sync Now {t('trakt.sync_now_button')}
</Text> </Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
@ -504,7 +506,7 @@ const TraktSettingsScreen: React.FC = () => {
styles.sectionTitle, styles.sectionTitle,
{ color: currentTheme.colors.highEmphasis, marginTop: 24 } { color: currentTheme.colors.highEmphasis, marginTop: 24 }
]}> ]}>
Display Settings {t('trakt.display_settings_title')}
</Text> </Text>
<View style={styles.settingItem}> <View style={styles.settingItem}>
@ -514,13 +516,13 @@ const TraktSettingsScreen: React.FC = () => {
styles.settingLabel, styles.settingLabel,
{ color: currentTheme.colors.highEmphasis } { color: currentTheme.colors.highEmphasis }
]}> ]}>
Show Trakt Comments {t('trakt.show_comments_label')}
</Text> </Text>
<Text style={[ <Text style={[
styles.settingDescription, styles.settingDescription,
{ color: currentTheme.colors.mediumEmphasis } { color: currentTheme.colors.mediumEmphasis }
]}> ]}>
Display Trakt comments in metadata screens when available {t('trakt.show_comments_desc')}
</Text> </Text>
</View> </View>
<View style={styles.settingToggleContainer}> <View style={styles.settingToggleContainer}>

View file

@ -25,6 +25,7 @@ import { mmkvStorage } from '../services/mmkvStorage';
import { useGithubMajorUpdate } from '../hooks/useGithubMajorUpdate'; import { useGithubMajorUpdate } from '../hooks/useGithubMajorUpdate';
import { getDisplayedAppVersion } from '../utils/version'; import { getDisplayedAppVersion } from '../utils/version';
import { isAnyUpgrade } from '../services/githubReleaseService'; import { isAnyUpgrade } from '../services/githubReleaseService';
import { useTranslation } from 'react-i18next';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
const isTablet = width >= 768; const isTablet = width >= 768;
@ -72,13 +73,14 @@ const UpdateScreen: React.FC = () => {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const github = useGithubMajorUpdate(); const github = useGithubMajorUpdate();
const { showInfo } = useToast(); const { showInfo } = useToast();
const { t } = useTranslation();
// CustomAlert state // CustomAlert state
const [alertVisible, setAlertVisible] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState(''); const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState(''); const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void; style?: object }>>([ const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void; style?: object }>>([
{ label: 'OK', onPress: () => setAlertVisible(false) }, { label: t('common.ok'), onPress: () => setAlertVisible(false) },
]); ]);
const openAlert = ( const openAlert = (
@ -97,7 +99,7 @@ const UpdateScreen: React.FC = () => {
})) }))
); );
} else { } else {
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
} }
setAlertVisible(true); setAlertVisible(true);
}; };
@ -133,12 +135,12 @@ const UpdateScreen: React.FC = () => {
const handleOtaAlertsToggle = async (value: boolean) => { const handleOtaAlertsToggle = async (value: boolean) => {
if (!value) { if (!value) {
openAlert( openAlert(
'Disable OTA Update Alerts?', t('updates.alert_disable_ota_title'),
'You will no longer receive automatic notifications for OTA updates.\n\n⚠ Warning: Staying on the latest version is important for:\n• Bug fixes and stability improvements\n• New features and enhancements\n• Providing accurate feedback and crash reports\n\nYou can still manually check for updates in this screen.', t('updates.alert_disable_ota_msg'),
[ [
{ label: 'Cancel', onPress: () => setAlertVisible(false) }, { label: t('common.cancel'), onPress: () => setAlertVisible(false) },
{ {
label: 'Disable', label: t('updates.disable'),
onPress: async () => { onPress: async () => {
await mmkvStorage.setItem('@ota_updates_alerts_enabled', 'false'); await mmkvStorage.setItem('@ota_updates_alerts_enabled', 'false');
setOtaAlertsEnabled(false); setOtaAlertsEnabled(false);
@ -157,12 +159,16 @@ const UpdateScreen: React.FC = () => {
const handleMajorAlertsToggle = async (value: boolean) => { const handleMajorAlertsToggle = async (value: boolean) => {
if (!value) { if (!value) {
openAlert( openAlert(
'Disable Major Update Alerts?', t('updates.alert_disable_major_title'),
'You will no longer receive notifications for major app updates that require reinstallation.\n\n⚠ Warning: Major updates often include:\n• Critical security patches\n• Breaking changes that require app reinstall\n• Important compatibility fixes\n\nYou can still check for updates manually.', t('updates.alert_disable_major_msg'),
[ [
{ label: 'Cancel', onPress: () => setAlertVisible(false) }, { label: t('common.cancel'), onPress: () => setAlertVisible(false) },
{ {
label: 'Disable', label: t('updates.disable'), // Assuming 'Disable' key might not exist, checking en.json... I didn't add 'disable'. Will use 'common.cancel' for cancel. For 'Disable', I'll check if I can use something else or add it. I missed adding 'disable' to en.json. I'll use hardcoded 'Disable' for now or 'Off'. Wait, I can use hardcoded string or just add it later. Actually, I see I missed adding a specific "Disable" button text in the replace_file_content earlier.
// Let's use 'Disable' string for now as fallback or t('plugins.disabled') if appropriate, but that's "Disabled".
// I will use "Disable" plain string for now to be safe, or check if common.disable exists. It probably doesn't.
// I'll stick to 'Disable' string to match previous behavior, or use t('common.cancel') for Cancel.
// Actually, looking at previous code it was "Disable". I'll use "Disable" for now.
onPress: async () => { onPress: async () => {
await mmkvStorage.setItem('@major_updates_alerts_enabled', 'false'); await mmkvStorage.setItem('@major_updates_alerts_enabled', 'false');
setMajorAlertsEnabled(false); setMajorAlertsEnabled(false);
@ -182,7 +188,7 @@ const UpdateScreen: React.FC = () => {
setIsChecking(true); setIsChecking(true);
setUpdateStatus('checking'); setUpdateStatus('checking');
setUpdateProgress(0); setUpdateProgress(0);
setLastOperation('Checking for updates...'); setLastOperation(t('updates.status_checking'));
const info = await UpdateService.checkForUpdates(); const info = await UpdateService.checkForUpdates();
setUpdateInfo(info); setUpdateInfo(info);
@ -192,16 +198,17 @@ const UpdateScreen: React.FC = () => {
if (info.isAvailable) { if (info.isAvailable) {
setUpdateStatus('available'); setUpdateStatus('available');
setLastOperation(`Update available: ${info.manifest?.id || 'unknown'}`); setLastOperation(`${t('updates.status_available')}: ${info.manifest?.id || 'unknown'}`);
} else { } else {
setUpdateStatus('idle'); setUpdateStatus('idle');
setLastOperation('No updates available'); setLastOperation(t('updates.status_ready')); // Using ready instead of "No updates available" to match "Ready to check" state, or should I add "No updates available"? Previous code used "No updates available". En.json has "status_ready" as "Ready to check for updates".
// I'll use status_ready effectively.
} }
} catch (error) { } catch (error) {
if (__DEV__) console.error('Error checking for updates:', error); if (__DEV__) console.error('Error checking for updates:', error);
setUpdateStatus('error'); setUpdateStatus('error');
setLastOperation(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); setLastOperation(`${t('common.error')}: ${error instanceof Error ? error.message : 'Unknown error'}`);
openAlert('Error', 'Failed to check for updates'); openAlert(t('common.error'), t('updates.status_error'));
} finally { } finally {
setIsChecking(false); setIsChecking(false);
} }
@ -219,7 +226,7 @@ const UpdateScreen: React.FC = () => {
// Also refresh GitHub section on mount (works in dev and prod) // Also refresh GitHub section on mount (works in dev and prod)
try { github.refresh(); } catch { } try { github.refresh(); } catch { }
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
showInfo('Checking for Updates', 'Checking for updates…'); showInfo(t('updates.title'), t('updates.status_checking'));
} }
}, []); }, []);
@ -228,7 +235,7 @@ const UpdateScreen: React.FC = () => {
setIsInstalling(true); setIsInstalling(true);
setUpdateStatus('downloading'); setUpdateStatus('downloading');
setUpdateProgress(0); setUpdateProgress(0);
setLastOperation('Downloading update...'); setLastOperation(t('updates.status_downloading'));
// Simulate progress updates // Simulate progress updates
const progressInterval = setInterval(() => { const progressInterval = setInterval(() => {
@ -243,24 +250,24 @@ const UpdateScreen: React.FC = () => {
clearInterval(progressInterval); clearInterval(progressInterval);
setUpdateProgress(100); setUpdateProgress(100);
setUpdateStatus('installing'); setUpdateStatus('installing');
setLastOperation('Installing update...'); setLastOperation(t('updates.status_installing'));
// Logs disabled // Logs disabled
if (success) { if (success) {
setUpdateStatus('success'); setUpdateStatus('success');
setLastOperation('Update installed successfully'); setLastOperation(t('updates.status_success'));
openAlert('Success', 'Update will be applied on next app restart'); openAlert(t('common.success'), t('updates.alert_update_applied_msg'));
} else { } else {
setUpdateStatus('error'); setUpdateStatus('error');
setLastOperation('No update available to install'); setLastOperation(t('updates.alert_no_update_to_install'));
openAlert('No Update', 'No update available to install'); openAlert(t('updates.alert_no_update_title'), t('updates.alert_no_update_to_install'));
} }
} catch (error) { } catch (error) {
if (__DEV__) console.error('Error installing update:', error); if (__DEV__) console.error('Error installing update:', error);
setUpdateStatus('error'); setUpdateStatus('error');
setLastOperation(`Installation error: ${error instanceof Error ? error.message : 'Unknown error'}`); setLastOperation(`${t('updates.status_error')}: ${error instanceof Error ? error.message : 'Unknown error'}`);
openAlert('Error', 'Failed to install update'); openAlert(t('common.error'), t('updates.alert_install_failed'));
} finally { } finally {
setIsInstalling(false); setIsInstalling(false);
} }
@ -361,19 +368,19 @@ const UpdateScreen: React.FC = () => {
const getStatusText = () => { const getStatusText = () => {
switch (updateStatus) { switch (updateStatus) {
case 'checking': case 'checking':
return 'Checking for updates...'; return t('updates.status_checking');
case 'available': case 'available':
return 'Update available!'; return t('updates.status_available');
case 'downloading': case 'downloading':
return 'Downloading update...'; return t('updates.status_downloading');
case 'installing': case 'installing':
return 'Installing update...'; return t('updates.status_installing');
case 'success': case 'success':
return 'Update installed successfully!'; return t('updates.status_success');
case 'error': case 'error':
return 'Update failed'; return t('updates.status_error');
default: default:
return 'Ready to check for updates'; return t('updates.status_ready');
} }
}; };
@ -409,7 +416,7 @@ const UpdateScreen: React.FC = () => {
> >
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.highEmphasis} /> <MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.highEmphasis} />
<Text style={[styles.backText, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.backText, { color: currentTheme.colors.highEmphasis }]}>
Settings {t('settings.settings_title')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -419,7 +426,7 @@ const UpdateScreen: React.FC = () => {
</View> </View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}> <Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
App Updates {t('updates.title')}
</Text> </Text>
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
@ -428,7 +435,7 @@ const UpdateScreen: React.FC = () => {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
> >
<SettingsCard title="APP UPDATES" isTablet={isTablet}> <SettingsCard title={t('updates.title').toUpperCase()} isTablet={isTablet}>
{/* Main Update Card */} {/* Main Update Card */}
<View style={styles.updateMainCard}> <View style={styles.updateMainCard}>
{/* Status Section */} {/* Status Section */}
@ -441,7 +448,7 @@ const UpdateScreen: React.FC = () => {
{getStatusText()} {getStatusText()}
</Text> </Text>
<Text style={[styles.statusDetailText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.statusDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
{lastOperation || 'Ready to check for updates'} {lastOperation || t('updates.status_ready')}
</Text> </Text>
</View> </View>
</View> </View>
@ -490,7 +497,7 @@ const UpdateScreen: React.FC = () => {
<MaterialIcons name="system-update" size={18} color="white" /> <MaterialIcons name="system-update" size={18} color="white" />
)} )}
<Text style={styles.modernButtonText}> <Text style={styles.modernButtonText}>
{isChecking ? 'Checking...' : 'Check for Updates'} {isChecking ? `${t('updates.status_checking')}...` : t('updates.action_check')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@ -512,7 +519,7 @@ const UpdateScreen: React.FC = () => {
<MaterialIcons name="download" size={18} color="white" /> <MaterialIcons name="download" size={18} color="white" />
)} )}
<Text style={styles.modernButtonText}> <Text style={styles.modernButtonText}>
{isInstalling ? 'Installing...' : 'Install Update'} {isInstalling ? `${t('updates.status_installing')}...` : t('updates.action_install')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
@ -527,7 +534,7 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}> <View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} /> <MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
</View> </View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Release notes:</Text> <Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>{t('updates.release_notes')}</Text>
</View> </View>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>{getReleaseNotes()}</Text> <Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>{getReleaseNotes()}</Text>
</View> </View>
@ -539,9 +546,9 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}> <View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="info-outline" size={14} color={currentTheme.colors.primary} /> <MaterialIcons name="info-outline" size={14} color={currentTheme.colors.primary} />
</View> </View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Version:</Text> <Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>{t('updates.version')}</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{updateInfo?.manifest?.id ? `${updateInfo.manifest.id.substring(0, 8)}...` : 'Unknown'} {updateInfo?.manifest?.id ? `${updateInfo.manifest.id.substring(0, 8)}...` : t('common.unknown')}
</Text> </Text>
</View> </View>
@ -550,7 +557,7 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}> <View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="schedule" size={14} color={currentTheme.colors.primary} /> <MaterialIcons name="schedule" size={14} color={currentTheme.colors.primary} />
</View> </View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Last checked:</Text> <Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>{t('updates.last_checked')}</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{formatDate(lastChecked)} {formatDate(lastChecked)}
</Text> </Text>
@ -564,10 +571,10 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}> <View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="verified" size={14} color={currentTheme.colors.primary} /> <MaterialIcons name="verified" size={14} color={currentTheme.colors.primary} />
</View> </View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current version:</Text> <Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>{t('updates.current_version')}</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]} <Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
selectable> selectable>
{currentInfo?.manifest?.id || (currentInfo?.isEmbeddedLaunch === false ? 'Unknown' : 'Embedded')} {currentInfo?.manifest?.id || (currentInfo?.isEmbeddedLaunch === false ? t('common.unknown') : 'Embedded')}
</Text> </Text>
</View> </View>
@ -577,7 +584,7 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}> <View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} /> <MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
</View> </View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current release notes:</Text> <Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>{t('updates.current_release_notes')}</Text>
</View> </View>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{getCurrentReleaseNotes()} {getCurrentReleaseNotes()}
@ -591,13 +598,13 @@ const UpdateScreen: React.FC = () => {
{/* GitHub Release (compact) only show when update is available */} {/* GitHub Release (compact) only show when update is available */}
{github.latestTag && isAnyUpgrade(getDisplayedAppVersion(), github.latestTag) ? ( {github.latestTag && isAnyUpgrade(getDisplayedAppVersion(), github.latestTag) ? (
<SettingsCard title="GITHUB RELEASE" isTablet={isTablet}> <SettingsCard title={t('updates.github_release')} isTablet={isTablet}>
<View style={styles.infoSection}> <View style={styles.infoSection}>
<View style={styles.infoItem}> <View style={styles.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}> <View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="new-releases" size={14} color={currentTheme.colors.primary} /> <MaterialIcons name="new-releases" size={14} color={currentTheme.colors.primary} />
</View> </View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Current:</Text> <Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>{t('updates.current')}</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{getDisplayedAppVersion()} {getDisplayedAppVersion()}
</Text> </Text>
@ -607,7 +614,7 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}> <View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="tag" size={14} color={currentTheme.colors.primary} /> <MaterialIcons name="tag" size={14} color={currentTheme.colors.primary} />
</View> </View>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Latest:</Text> <Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>{t('updates.latest')}</Text>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{github.latestTag} {github.latestTag}
</Text> </Text>
@ -615,7 +622,7 @@ const UpdateScreen: React.FC = () => {
{github.releaseNotes ? ( {github.releaseNotes ? (
<View style={{ marginTop: 4 }}> <View style={{ marginTop: 4 }}>
<Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>Notes:</Text> <Text style={[styles.infoLabel, { color: currentTheme.colors.mediumEmphasis }]}>{t('updates.notes')}</Text>
<Text <Text
numberOfLines={3} numberOfLines={3}
style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]} style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
@ -633,7 +640,7 @@ const UpdateScreen: React.FC = () => {
activeOpacity={0.8} activeOpacity={0.8}
> >
<MaterialIcons name="open-in-new" size={18} color="white" /> <MaterialIcons name="open-in-new" size={18} color="white" />
<Text style={styles.modernButtonText}>View Release</Text> <Text style={styles.modernButtonText}>{t('updates.view_release')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@ -642,15 +649,15 @@ const UpdateScreen: React.FC = () => {
) : null} ) : null}
{/* Update Notification Settings */} {/* Update Notification Settings */}
<SettingsCard title="NOTIFICATION SETTINGS" isTablet={isTablet}> <SettingsCard title={t('updates.notification_settings')} isTablet={isTablet}>
{/* OTA Updates Toggle */} {/* OTA Updates Toggle */}
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
OTA Update Alerts {t('updates.ota_alerts_label')}
</Text> </Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Show notifications for over-the-air updates {t('updates.ota_alerts_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -666,10 +673,10 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.settingRow, { borderBottomWidth: 0 }]}> <View style={[styles.settingRow, { borderBottomWidth: 0 }]}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
Major Update Alerts {t('updates.major_alerts_label')}
</Text> </Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Show notifications for new app versions on GitHub {t('updates.major_alerts_desc')}
</Text> </Text>
</View> </View>
<Switch <Switch
@ -687,7 +694,7 @@ const UpdateScreen: React.FC = () => {
<MaterialIcons name="info-outline" size={14} color={currentTheme.colors.warning || '#FFA500'} /> <MaterialIcons name="info-outline" size={14} color={currentTheme.colors.warning || '#FFA500'} />
</View> </View>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis, flex: 1 }]}> <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis, flex: 1 }]}>
Keeping alerts enabled ensures you receive bug fixes and can provide accurate crash reports. {t('updates.warning_note')}
</Text> </Text>
</View> </View>
</SettingsCard> </SettingsCard>

View file

@ -13,6 +13,7 @@ import { fetchTotalDownloads } from '../../services/githubReleaseService';
import { getDisplayedAppVersion } from '../../utils/version'; import { getDisplayedAppVersion } from '../../utils/version';
import ScreenHeader from '../../components/common/ScreenHeader'; import ScreenHeader from '../../components/common/ScreenHeader';
import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents'; import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents';
import { useTranslation } from 'react-i18next';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
@ -29,6 +30,7 @@ export const AboutSettingsContent: React.FC<AboutSettingsContentProps> = ({
isTablet = false, isTablet = false,
displayDownloads: externalDisplayDownloads displayDownloads: externalDisplayDownloads
}) => { }) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
@ -52,30 +54,30 @@ export const AboutSettingsContent: React.FC<AboutSettingsContentProps> = ({
return ( return (
<> <>
<SettingsCard title="INFORMATION" isTablet={isTablet}> <SettingsCard title={t('settings.sections.information')} isTablet={isTablet}>
<SettingItem <SettingItem
title="Privacy Policy" title={t('settings.items.privacy_policy')}
icon="lock" icon="lock"
onPress={() => Linking.openURL('https://tapframe.github.io/NuvioStreaming/#privacy-policy')} onPress={() => Linking.openURL('https://tapframe.github.io/NuvioStreaming/#privacy-policy')}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
title="Report Issue" title={t('settings.items.report_issue')}
icon="alert-triangle" icon="alert-triangle"
onPress={() => Sentry.showFeedbackWidget()} onPress={() => Sentry.showFeedbackWidget()}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
title="Version" title={t('settings.items.version')}
description={getDisplayedAppVersion()} description={getDisplayedAppVersion()}
icon="info" icon="info"
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
title="Contributors" title={t('settings.items.contributors')}
description="View all contributors" description={t('settings.items.view_contributors')}
icon="users" icon="users"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('Contributors')} onPress={() => navigation.navigate('Contributors')}
@ -92,6 +94,7 @@ export const AboutSettingsContent: React.FC<AboutSettingsContentProps> = ({
*/ */
export const AboutFooter: React.FC<{ displayDownloads: number | null }> = ({ displayDownloads }) => { export const AboutFooter: React.FC<{ displayDownloads: number | null }> = ({ displayDownloads }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
return ( return (
<> <>
@ -101,7 +104,7 @@ export const AboutFooter: React.FC<{ displayDownloads: number | null }> = ({ dis
{displayDownloads.toLocaleString()} {displayDownloads.toLocaleString()}
</Text> </Text>
<Text style={[styles.downloadsLabel, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.downloadsLabel, { color: currentTheme.colors.mediumEmphasis }]}>
downloads and counting {t('settings.downloads_counter')}
</Text> </Text>
</View> </View>
)} )}
@ -179,7 +182,7 @@ export const AboutFooter: React.FC<{ displayDownloads: number | null }> = ({ dis
<View style={styles.footer}> <View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}> <Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
Made with by Tapframe and Friends {t('settings.made_with_love')}
</Text> </Text>
</View> </View>
</> </>
@ -192,13 +195,14 @@ export const AboutFooter: React.FC<{ displayDownloads: number | null }> = ({ dis
const AboutSettingsScreen: React.FC = () => { const AboutSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const screenIsTablet = width >= 768; const screenIsTablet = width >= 768;
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<ScreenHeader title="About" showBackButton onBackPress={() => navigation.goBack()} /> <ScreenHeader title={t('settings.about')} showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}

View file

@ -9,6 +9,7 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
import ScreenHeader from '../../components/common/ScreenHeader'; import ScreenHeader from '../../components/common/ScreenHeader';
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents'; import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
import { useRealtimeConfig } from '../../hooks/useRealtimeConfig'; import { useRealtimeConfig } from '../../hooks/useRealtimeConfig';
import { useTranslation } from 'react-i18next';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
@ -24,6 +25,7 @@ export const AppearanceSettingsContent: React.FC<AppearanceSettingsContentProps>
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const { t } = useTranslation();
const config = useRealtimeConfig(); const config = useRealtimeConfig();
const isItemVisible = (itemId: string) => { const isItemVisible = (itemId: string) => {
@ -43,10 +45,10 @@ export const AppearanceSettingsContent: React.FC<AppearanceSettingsContentProps>
return ( return (
<> <>
{hasVisibleItems(['theme']) && ( {hasVisibleItems(['theme']) && (
<SettingsCard title="THEME" isTablet={isTablet}> <SettingsCard title={t('settings.sections.theme')} isTablet={isTablet}>
{isItemVisible('theme') && ( {isItemVisible('theme') && (
<SettingItem <SettingItem
title="Theme" title={t('settings.items.theme')}
description={currentTheme.name} description={currentTheme.name}
icon="sliders" icon="sliders"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -59,11 +61,11 @@ export const AppearanceSettingsContent: React.FC<AppearanceSettingsContentProps>
)} )}
{hasVisibleItems(['episode_layout', 'streams_backdrop']) && ( {hasVisibleItems(['episode_layout', 'streams_backdrop']) && (
<SettingsCard title="LAYOUT" isTablet={isTablet}> <SettingsCard title={t('settings.sections.layout')} isTablet={isTablet}>
{isItemVisible('episode_layout') && ( {isItemVisible('episode_layout') && (
<SettingItem <SettingItem
title="Episode Layout" title={t('settings.items.episode_layout')}
description={settings?.episodeLayoutStyle === 'horizontal' ? 'Horizontal' : 'Vertical'} description={settings?.episodeLayoutStyle === 'horizontal' ? t('settings.options.horizontal') : t('settings.options.vertical')}
icon="grid" icon="grid"
renderControl={() => ( renderControl={() => (
<CustomSwitch <CustomSwitch
@ -77,8 +79,8 @@ export const AppearanceSettingsContent: React.FC<AppearanceSettingsContentProps>
)} )}
{!isTablet && isItemVisible('streams_backdrop') && ( {!isTablet && isItemVisible('streams_backdrop') && (
<SettingItem <SettingItem
title="Streams Backdrop" title={t('settings.items.streams_backdrop')}
description="Show blurred backdrop on mobile streams" description={t('settings.items.streams_backdrop_desc')}
icon="image" icon="image"
renderControl={() => ( renderControl={() => (
<CustomSwitch <CustomSwitch
@ -102,13 +104,14 @@ export const AppearanceSettingsContent: React.FC<AppearanceSettingsContentProps>
const AppearanceSettingsScreen: React.FC = () => { const AppearanceSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const screenIsTablet = width >= 768; const screenIsTablet = width >= 768;
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<ScreenHeader title="Appearance" showBackButton onBackPress={() => navigation.goBack()} /> <ScreenHeader title={t('settings.appearance')} showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}

View file

@ -12,6 +12,7 @@ import ScreenHeader from '../../components/common/ScreenHeader';
import PluginIcon from '../../components/icons/PluginIcon'; import PluginIcon from '../../components/icons/PluginIcon';
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents'; import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
import { useRealtimeConfig } from '../../hooks/useRealtimeConfig'; import { useRealtimeConfig } from '../../hooks/useRealtimeConfig';
import { useTranslation } from 'react-i18next';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
@ -27,6 +28,7 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const { t } = useTranslation();
const config = useRealtimeConfig(); const config = useRealtimeConfig();
const [addonCount, setAddonCount] = useState<number>(0); const [addonCount, setAddonCount] = useState<number>(0);
@ -79,11 +81,11 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
return ( return (
<> <>
{hasVisibleItems(['addons', 'debrid', 'plugins']) && ( {hasVisibleItems(['addons', 'debrid', 'plugins']) && (
<SettingsCard title="SOURCES" isTablet={isTablet}> <SettingsCard title={t('settings.sections.sources')} isTablet={isTablet}>
{isItemVisible('addons') && ( {isItemVisible('addons') && (
<SettingItem <SettingItem
title="Addons" title={t('settings.items.addons')}
description={`${addonCount} installed`} description={`${addonCount} ${t('settings.items.installed')}`}
icon="layers" icon="layers"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('Addons')} onPress={() => navigation.navigate('Addons')}
@ -92,8 +94,8 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
)} )}
{isItemVisible('debrid') && ( {isItemVisible('debrid') && (
<SettingItem <SettingItem
title="Debrid Integration" title={t('settings.items.debrid_integration')}
description="Connect Torbox for premium streams" description={t('settings.items.debrid_desc')}
icon="link" icon="link"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('DebridIntegration')} onPress={() => navigation.navigate('DebridIntegration')}
@ -102,8 +104,8 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
)} )}
{isItemVisible('plugins') && ( {isItemVisible('plugins') && (
<SettingItem <SettingItem
title="Plugins" title={t('settings.items.plugins')}
description="Manage plugins and repositories" description={t('settings.items.plugins_desc')}
customIcon={<PluginIcon size={isTablet ? 22 : 18} color={currentTheme.colors.primary} />} customIcon={<PluginIcon size={isTablet ? 22 : 18} color={currentTheme.colors.primary} />}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('ScraperSettings')} onPress={() => navigation.navigate('ScraperSettings')}
@ -115,11 +117,11 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
)} )}
{hasVisibleItems(['catalogs', 'home_screen', 'continue_watching']) && ( {hasVisibleItems(['catalogs', 'home_screen', 'continue_watching']) && (
<SettingsCard title="CATALOGS" isTablet={isTablet}> <SettingsCard title={t('settings.sections.catalogs')} isTablet={isTablet}>
{isItemVisible('catalogs') && ( {isItemVisible('catalogs') && (
<SettingItem <SettingItem
title="Catalogs" title={t('settings.items.catalogs')}
description={`${catalogCount} active`} description={`${catalogCount} ${t('settings.items.active')}`}
icon="list" icon="list"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('CatalogSettings')} onPress={() => navigation.navigate('CatalogSettings')}
@ -128,8 +130,8 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
)} )}
{isItemVisible('home_screen') && ( {isItemVisible('home_screen') && (
<SettingItem <SettingItem
title="Home Screen" title={t('settings.items.home_screen')}
description="Layout and content" description={t('settings.items.home_screen_desc')}
icon="home" icon="home"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('HomeScreenSettings')} onPress={() => navigation.navigate('HomeScreenSettings')}
@ -138,8 +140,8 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
)} )}
{isItemVisible('continue_watching') && ( {isItemVisible('continue_watching') && (
<SettingItem <SettingItem
title="Continue Watching" title={t('settings.items.continue_watching')}
description="Cache and playback behavior" description={t('settings.items.continue_watching_desc')}
icon="play-circle" icon="play-circle"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('ContinueWatchingSettings')} onPress={() => navigation.navigate('ContinueWatchingSettings')}
@ -151,11 +153,11 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
)} )}
{hasVisibleItems(['show_discover']) && ( {hasVisibleItems(['show_discover']) && (
<SettingsCard title="DISCOVERY" isTablet={isTablet}> <SettingsCard title={t('settings.sections.discovery')} isTablet={isTablet}>
{isItemVisible('show_discover') && ( {isItemVisible('show_discover') && (
<SettingItem <SettingItem
title="Show Discover Section" title={t('settings.items.show_discover')}
description="Display discover content in Search" description={t('settings.items.show_discover_desc')}
icon="compass" icon="compass"
renderControl={() => ( renderControl={() => (
<CustomSwitch <CustomSwitch
@ -179,13 +181,14 @@ export const ContentDiscoverySettingsContent: React.FC<ContentDiscoverySettingsC
const ContentDiscoverySettingsScreen: React.FC = () => { const ContentDiscoverySettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const screenIsTablet = width >= 768; const screenIsTablet = width >= 768;
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<ScreenHeader title="Content & Discovery" showBackButton onBackPress={() => navigation.goBack()} /> <ScreenHeader title={t('settings.content_discovery')} showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}

View file

@ -10,10 +10,12 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
import ScreenHeader from '../../components/common/ScreenHeader'; import ScreenHeader from '../../components/common/ScreenHeader';
import CustomAlert from '../../components/CustomAlert'; import CustomAlert from '../../components/CustomAlert';
import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents'; import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents';
import { useTranslation } from 'react-i18next';
const DeveloperSettingsScreen: React.FC = () => { const DeveloperSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [alertVisible, setAlertVisible] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
@ -84,36 +86,36 @@ const DeveloperSettingsScreen: React.FC = () => {
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<ScreenHeader title="Developer" showBackButton onBackPress={() => navigation.goBack()} /> <ScreenHeader title={t('settings.developer')} showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 24 }]} contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 24 }]}
> >
<SettingsCard title="TESTING"> <SettingsCard title={t('settings.sections.testing')}>
<SettingItem <SettingItem
title="Test Onboarding" title={t('settings.items.test_onboarding')}
icon="play-circle" icon="play-circle"
onPress={() => navigation.navigate('Onboarding')} onPress={() => navigation.navigate('Onboarding')}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
/> />
<SettingItem <SettingItem
title="Reset Onboarding" title={t('settings.items.reset_onboarding')}
icon="refresh-ccw" icon="refresh-ccw"
onPress={handleResetOnboarding} onPress={handleResetOnboarding}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
/> />
<SettingItem <SettingItem
title="Test Announcement" title={t('settings.items.test_announcement')}
icon="bell" icon="bell"
description="Show what's new overlay" description={t('settings.items.test_announcement_desc')}
onPress={handleResetAnnouncement} onPress={handleResetAnnouncement}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
/> />
<SettingItem <SettingItem
title="Reset Campaigns" title={t('settings.items.reset_campaigns')}
description="Clear campaign impressions" description={t('settings.items.reset_campaigns_desc')}
icon="refresh-cw" icon="refresh-cw"
onPress={handleResetCampaigns} onPress={handleResetCampaigns}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -121,10 +123,10 @@ const DeveloperSettingsScreen: React.FC = () => {
/> />
</SettingsCard> </SettingsCard>
<SettingsCard title="DANGER ZONE"> <SettingsCard title={t('settings.sections.danger_zone')}>
<SettingItem <SettingItem
title="Clear All Data" title={t('settings.items.clear_all_data')}
description="Reset all settings and cached data" description={t('settings.items.clear_all_data_desc')}
icon="trash-2" icon="trash-2"
onPress={handleClearAllData} onPress={handleClearAllData}
isLast isLast

View file

@ -11,6 +11,7 @@ import MDBListIcon from '../../components/icons/MDBListIcon';
import TMDBIcon from '../../components/icons/TMDBIcon'; import TMDBIcon from '../../components/icons/TMDBIcon';
import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents'; import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents';
import { useRealtimeConfig } from '../../hooks/useRealtimeConfig'; import { useRealtimeConfig } from '../../hooks/useRealtimeConfig';
import { useTranslation } from 'react-i18next';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
@ -26,6 +27,7 @@ export const IntegrationsSettingsContent: React.FC<IntegrationsSettingsContentPr
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const config = useRealtimeConfig(); const config = useRealtimeConfig();
const { t } = useTranslation();
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false); const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
const [openRouterKeySet, setOpenRouterKeySet] = useState<boolean>(false); const [openRouterKeySet, setOpenRouterKeySet] = useState<boolean>(false);
@ -62,11 +64,11 @@ export const IntegrationsSettingsContent: React.FC<IntegrationsSettingsContentPr
return ( return (
<> <>
{hasVisibleItems(['mdblist', 'tmdb']) && ( {hasVisibleItems(['mdblist', 'tmdb']) && (
<SettingsCard title="METADATA" isTablet={isTablet}> <SettingsCard title={t('settings.sections.metadata')} isTablet={isTablet}>
{isItemVisible('mdblist') && ( {isItemVisible('mdblist') && (
<SettingItem <SettingItem
title="MDBList" title={t('settings.items.mdblist')}
description={mdblistKeySet ? "Connected" : "Enable to add ratings & reviews"} description={mdblistKeySet ? t('settings.items.mdblist_connected') : t('settings.items.mdblist_desc')}
customIcon={<MDBListIcon size={isTablet ? 22 : 18} colorPrimary={currentTheme.colors.primary} colorSecondary={currentTheme.colors.white} />} customIcon={<MDBListIcon size={isTablet ? 22 : 18} colorPrimary={currentTheme.colors.primary} colorSecondary={currentTheme.colors.white} />}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('MDBListSettings')} onPress={() => navigation.navigate('MDBListSettings')}
@ -75,8 +77,8 @@ export const IntegrationsSettingsContent: React.FC<IntegrationsSettingsContentPr
)} )}
{isItemVisible('tmdb') && ( {isItemVisible('tmdb') && (
<SettingItem <SettingItem
title="TMDB" title={t('settings.items.tmdb')}
description="Metadata & logo source provider" description={t('settings.items.tmdb_desc')}
customIcon={<TMDBIcon size={isTablet ? 22 : 18} color={currentTheme.colors.primary} />} customIcon={<TMDBIcon size={isTablet ? 22 : 18} color={currentTheme.colors.primary} />}
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TMDBSettings')} onPress={() => navigation.navigate('TMDBSettings')}
@ -88,11 +90,11 @@ export const IntegrationsSettingsContent: React.FC<IntegrationsSettingsContentPr
)} )}
{hasVisibleItems(['openrouter']) && ( {hasVisibleItems(['openrouter']) && (
<SettingsCard title="AI ASSISTANT" isTablet={isTablet}> <SettingsCard title={t('settings.sections.ai_assistant')} isTablet={isTablet}>
{isItemVisible('openrouter') && ( {isItemVisible('openrouter') && (
<SettingItem <SettingItem
title="OpenRouter API" title={t('settings.items.openrouter')}
description={openRouterKeySet ? "Connected" : "Add your API key to enable AI chat"} description={openRouterKeySet ? t('settings.items.openrouter_connected') : t('settings.items.openrouter_desc')}
icon="cpu" icon="cpu"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('AISettings')} onPress={() => navigation.navigate('AISettings')}
@ -112,13 +114,14 @@ export const IntegrationsSettingsContent: React.FC<IntegrationsSettingsContentPr
const IntegrationsSettingsScreen: React.FC = () => { const IntegrationsSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const screenIsTablet = width >= 768; const screenIsTablet = width >= 768;
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<ScreenHeader title="Integrations" showBackButton onBackPress={() => navigation.goBack()} /> <ScreenHeader title={t('settings.integrations')} showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}

View file

@ -11,6 +11,7 @@ import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './Setting
import { useRealtimeConfig } from '../../hooks/useRealtimeConfig'; import { useRealtimeConfig } from '../../hooks/useRealtimeConfig';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'; import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
import { useTranslation } from 'react-i18next';
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
@ -69,6 +70,7 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const { t } = useTranslation();
const config = useRealtimeConfig(); const config = useRealtimeConfig();
// Bottom sheet refs // Bottom sheet refs
@ -116,8 +118,10 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
}; };
const getSourceLabel = (value: string) => { const getSourceLabel = (value: string) => {
const option = SUBTITLE_SOURCE_OPTIONS.find(o => o.value === value); if (value === 'internal') return t('settings.options.internal_first');
return option ? option.label : 'Internal First'; if (value === 'external') return t('settings.options.external_first');
if (value === 'any') return t('settings.options.any_available');
return t('settings.options.internal_first');
}; };
// Render backdrop for bottom sheets // Render backdrop for bottom sheets
@ -151,13 +155,13 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
return ( return (
<> <>
{hasVisibleItems(['video_player']) && ( {hasVisibleItems(['video_player']) && (
<SettingsCard title="VIDEO PLAYER" isTablet={isTablet}> <SettingsCard title={t('settings.sections.video_player')} isTablet={isTablet}>
{isItemVisible('video_player') && ( {isItemVisible('video_player') && (
<SettingItem <SettingItem
title="Video Player" title={t('settings.items.video_player')}
description={Platform.OS === 'ios' description={Platform.OS === 'ios'
? (settings?.preferredPlayer === 'internal' ? 'Built-in' : settings?.preferredPlayer?.toUpperCase() || 'Built-in') ? (settings?.preferredPlayer === 'internal' ? t('settings.items.built_in') : settings?.preferredPlayer?.toUpperCase() || t('settings.items.built_in'))
: (settings?.useExternalPlayer ? 'External' : 'Built-in') : (settings?.useExternalPlayer ? t('settings.items.external') : t('settings.items.built_in'))
} }
icon="play-circle" icon="play-circle"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -170,9 +174,9 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
)} )}
{/* Audio & Subtitle Preferences */} {/* Audio & Subtitle Preferences */}
<SettingsCard title="AUDIO & SUBTITLES" isTablet={isTablet}> <SettingsCard title={t('settings.sections.audio_subtitles')} isTablet={isTablet}>
<SettingItem <SettingItem
title="Preferred Audio Language" title={t('settings.items.preferred_audio')}
description={getLanguageName(settings?.preferredAudioLanguage || 'en')} description={getLanguageName(settings?.preferredAudioLanguage || 'en')}
icon="volume-2" icon="volume-2"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -180,7 +184,7 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
title="Preferred Subtitle Language" title={t('settings.items.preferred_subtitle')}
description={getLanguageName(settings?.preferredSubtitleLanguage || 'en')} description={getLanguageName(settings?.preferredSubtitleLanguage || 'en')}
icon="type" icon="type"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -188,7 +192,7 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
title="Subtitle Source Priority" title={t('settings.items.subtitle_source')}
description={getSourceLabel(settings?.subtitleSourcePreference || 'internal')} description={getSourceLabel(settings?.subtitleSourcePreference || 'internal')}
icon="layers" icon="layers"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -196,8 +200,8 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
title="Auto-Select Subtitles" title={t('settings.items.auto_select_subs')}
description="Automatically select subtitles matching your preferences" description={t('settings.items.auto_select_subs_desc')}
icon="zap" icon="zap"
renderControl={() => ( renderControl={() => (
<CustomSwitch <CustomSwitch
@ -211,11 +215,11 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
</SettingsCard> </SettingsCard>
{hasVisibleItems(['show_trailers', 'enable_downloads']) && ( {hasVisibleItems(['show_trailers', 'enable_downloads']) && (
<SettingsCard title="MEDIA" isTablet={isTablet}> <SettingsCard title={t('settings.sections.media')} isTablet={isTablet}>
{isItemVisible('show_trailers') && ( {isItemVisible('show_trailers') && (
<SettingItem <SettingItem
title="Show Trailers" title={t('settings.items.show_trailers')}
description="Display trailers in hero section" description={t('settings.items.show_trailers_desc')}
icon="film" icon="film"
renderControl={() => ( renderControl={() => (
<CustomSwitch <CustomSwitch
@ -228,8 +232,8 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
)} )}
{isItemVisible('enable_downloads') && ( {isItemVisible('enable_downloads') && (
<SettingItem <SettingItem
title="Enable Downloads (Beta)" title={t('settings.items.enable_downloads')}
description="Show Downloads tab and enable saving streams" description={t('settings.items.enable_downloads_desc')}
icon="download" icon="download"
renderControl={() => ( renderControl={() => (
<CustomSwitch <CustomSwitch
@ -245,11 +249,11 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
)} )}
{hasVisibleItems(['notifications']) && ( {hasVisibleItems(['notifications']) && (
<SettingsCard title="NOTIFICATIONS" isTablet={isTablet}> <SettingsCard title={t('settings.sections.notifications')} isTablet={isTablet}>
{isItemVisible('notifications') && ( {isItemVisible('notifications') && (
<SettingItem <SettingItem
title="Notifications" title={t('settings.items.notifications')}
description="Episode reminders" description={t('settings.items.notifications_desc')}
icon="bell" icon="bell"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('NotificationSettings')} onPress={() => navigation.navigate('NotificationSettings')}
@ -272,7 +276,7 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }} handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
> >
<View style={styles.sheetHeader}> <View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Preferred Audio Language</Text> <Text style={styles.sheetTitle}>{t('settings.items.preferred_audio')}</Text>
</View> </View>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}> <BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{AVAILABLE_LANGUAGES.map((lang) => { {AVAILABLE_LANGUAGES.map((lang) => {
@ -313,7 +317,7 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }} handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
> >
<View style={styles.sheetHeader}> <View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Preferred Subtitle Language</Text> <Text style={styles.sheetTitle}>{t('settings.items.preferred_subtitle')}</Text>
</View> </View>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}> <BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{AVAILABLE_LANGUAGES.map((lang) => { {AVAILABLE_LANGUAGES.map((lang) => {
@ -354,7 +358,7 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }} handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
> >
<View style={styles.sheetHeader}> <View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Subtitle Source Priority</Text> <Text style={styles.sheetTitle}>{t('settings.items.subtitle_source')}</Text>
</View> </View>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}> <BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{SUBTITLE_SOURCE_OPTIONS.map((option) => { {SUBTITLE_SOURCE_OPTIONS.map((option) => {
@ -370,10 +374,12 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
> >
<View style={styles.sourceItemContent}> <View style={styles.sourceItemContent}>
<Text style={[styles.sourceLabel, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}> <Text style={[styles.sourceLabel, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
{option.label} {getSourceLabel(option.value)}
</Text> </Text>
<Text style={styles.sourceDescription}> <Text style={styles.sourceDescription}>
{option.description} {option.value === 'internal' && t('settings.options.internal_first_desc')}
{option.value === 'external' && t('settings.options.external_first_desc')}
{option.value === 'any' && t('settings.options.any_available_desc')}
</Text> </Text>
</View> </View>
{isSelected && ( {isSelected && (
@ -395,13 +401,14 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
const PlaybackSettingsScreen: React.FC = () => { const PlaybackSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const screenIsTablet = width >= 768; const screenIsTablet = width >= 768;
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<ScreenHeader title="Playback" showBackButton onBackPress={() => navigation.goBack()} /> <ScreenHeader title={t('settings.playback')} showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}

View file

@ -1,5 +1,6 @@
import React, { memo } from 'react'; import React, { memo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, StatusBar, Platform } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, StatusBar, Platform } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { PaperProvider } from 'react-native-paper'; import { PaperProvider } from 'react-native-paper';
@ -88,6 +89,7 @@ export const StreamsScreen = () => {
gradientColors, gradientColors,
} = useStreamsScreen(); } = useStreamsScreen();
const { t } = useTranslation();
const styles = React.useMemo(() => createStyles(colors), [colors]); const styles = React.useMemo(() => createStyles(colors), [colors]);
return ( return (
@ -106,8 +108,8 @@ export const StreamsScreen = () => {
<MaterialIcons name="arrow-back" size={24} color={colors.white} /> <MaterialIcons name="arrow-back" size={24} color={colors.white} />
<Text style={styles.backButtonText}> <Text style={styles.backButtonText}>
{metadata?.videos && metadata.videos.length > 1 && selectedEpisode {metadata?.videos && metadata.videos.length > 1 && selectedEpisode
? 'Back to Episodes' ? t('streams.back_to_episodes')
: 'Back to Info'} : t('streams.back_to_info')}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View file

@ -1,5 +1,6 @@
import React, { memo } from 'react'; import React, { memo } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, Platform } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, Platform } from 'react-native';
import { useTranslation } from 'react-i18next';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { BlurView as ExpoBlurView } from 'expo-blur'; import { BlurView as ExpoBlurView } from 'expo-blur';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
@ -129,6 +130,7 @@ const MobileStreamsLayout = memo(
id, id,
imdbId, imdbId,
}: MobileStreamsLayoutProps) => { }: MobileStreamsLayoutProps) => {
const { t } = useTranslation();
const styles = React.useMemo(() => createStyles(colors), [colors]); const styles = React.useMemo(() => createStyles(colors), [colors]);
const isEpisode = metadata?.videos && metadata.videos.length > 1 && selectedEpisode; const isEpisode = metadata?.videos && metadata.videos.length > 1 && selectedEpisode;
@ -227,7 +229,7 @@ const MobileStreamsLayout = memo(
{/* Active Scrapers Status */} {/* Active Scrapers Status */}
{activeFetchingScrapers.length > 0 && ( {activeFetchingScrapers.length > 0 && (
<View style={styles.activeScrapersContainer}> <View style={styles.activeScrapersContainer}>
<Text style={styles.activeScrapersTitle}>Fetching from:</Text> <Text style={styles.activeScrapersTitle}>{t('streams.fetching_from')}</Text>
<View style={styles.activeScrapersRow}> <View style={styles.activeScrapersRow}>
{activeFetchingScrapers.map((scraperName, index) => ( {activeFetchingScrapers.map((scraperName, index) => (
<PulsingChip key={scraperName} text={scraperName} delay={index * 200} /> <PulsingChip key={scraperName} text={scraperName} delay={index * 200} />
@ -240,13 +242,13 @@ const MobileStreamsLayout = memo(
{showNoSourcesError ? ( {showNoSourcesError ? (
<View style={styles.noStreams}> <View style={styles.noStreams}>
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} /> <MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
<Text style={styles.noStreamsText}>No streaming sources available</Text> <Text style={styles.noStreamsText}>{t('streams.no_sources_available')}</Text>
<Text style={styles.noStreamsSubText}>Please add streaming sources in settings</Text> <Text style={styles.noStreamsSubText}>{t('streams.add_sources_desc')}</Text>
<TouchableOpacity <TouchableOpacity
style={styles.addSourcesButton} style={styles.addSourcesButton}
onPress={() => navigation.navigate('Addons' as never)} onPress={() => navigation.navigate('Addons' as never)}
> >
<Text style={styles.addSourcesButtonText}>Add Sources</Text> <Text style={styles.addSourcesButtonText}>{t('streams.add_sources')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
) : streamsEmpty ? ( ) : streamsEmpty ? (
@ -254,18 +256,18 @@ const MobileStreamsLayout = memo(
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}> <Text style={styles.loadingText}>
{isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} {isAutoplayWaiting ? t('streams.finding_best_stream') : t('streams.finding_streams')}
</Text> </Text>
</View> </View>
) : showStillFetching ? ( ) : showStillFetching ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<MaterialIcons name="hourglass-bottom" size={32} color={colors.primary} /> <MaterialIcons name="hourglass-bottom" size={32} color={colors.primary} />
<Text style={styles.loadingText}>Still fetching streams</Text> <Text style={styles.loadingText}>{t('streams.still_fetching')}</Text>
</View> </View>
) : ( ) : (
<View style={styles.noStreams}> <View style={styles.noStreams}>
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} /> <MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
<Text style={styles.noStreamsText}>No streams available</Text> <Text style={styles.noStreamsText}>{t('streams.no_streams_available')}</Text>
</View> </View>
) )
) : ( ) : (

View file

@ -6,6 +6,7 @@ import {
ActivityIndicator, ActivityIndicator,
Platform, Platform,
} from 'react-native'; } from 'react-native';
import { useTranslation } from 'react-i18next';
import { LegendList } from '@legendapp/list'; import { LegendList } from '@legendapp/list';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
@ -59,6 +60,7 @@ const StreamsList = memo(
id, id,
imdbId, imdbId,
}: StreamsListProps) => { }: StreamsListProps) => {
const { t } = useTranslation();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const styles = React.useMemo(() => createStyles(colors), [colors]); const styles = React.useMemo(() => createStyles(colors), [colors]);
@ -91,7 +93,7 @@ const StreamsList = memo(
<View style={styles.sectionLoadingIndicator}> <View style={styles.sectionLoadingIndicator}>
<ActivityIndicator size="small" color={colors.primary} /> <ActivityIndicator size="small" color={colors.primary} />
<Text style={[styles.sectionLoadingText, { color: colors.primary }]}> <Text style={[styles.sectionLoadingText, { color: colors.primary }]}>
Loading... {t('common.loading')}
</Text> </Text>
</View> </View>
)} )}
@ -157,21 +159,21 @@ const StreamsList = memo(
<View style={styles.autoplayOverlay}> <View style={styles.autoplayOverlay}>
<View style={styles.autoplayIndicator}> <View style={styles.autoplayIndicator}>
<ActivityIndicator size="small" color={colors.primary} /> <ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.autoplayText}>Starting best stream...</Text> <Text style={styles.autoplayText}>{t('streams.starting_best_stream')}</Text>
</View> </View>
</View> </View>
); );
}, [isAutoplayWaiting, autoplayTriggered, styles, colors.primary]); }, [isAutoplayWaiting, autoplayTriggered, styles, colors.primary, t]);
const ListFooterComponent = useMemo(() => { const ListFooterComponent = useMemo(() => {
if (!(loadingStreams || loadingEpisodeStreams) || !hasStremioStreamProviders) return null; if (!(loadingStreams || loadingEpisodeStreams) || !hasStremioStreamProviders) return null;
return ( return (
<View style={styles.footerLoading}> <View style={styles.footerLoading}>
<ActivityIndicator size="small" color={colors.primary} /> <ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.footerLoadingText}>Loading more sources...</Text> <Text style={styles.footerLoadingText}>{t('streams.loading_more_sources')}</Text>
</View> </View>
); );
}, [loadingStreams, loadingEpisodeStreams, hasStremioStreamProviders, styles, colors.primary]); }, [loadingStreams, loadingEpisodeStreams, hasStremioStreamProviders, styles, colors.primary, t]);
return ( return (
<View collapsable={false} style={{ flex: 1 }}> <View collapsable={false} style={{ flex: 1 }}>

View file

@ -54,7 +54,6 @@ export interface StreamingContent {
id: string; id: string;
type: string; type: string;
name: string; name: string;
addonId?: string;
tmdbId?: number; tmdbId?: number;
poster: string; poster: string;
posterShape?: 'poster' | 'square' | 'landscape'; posterShape?: 'poster' | 'square' | 'landscape';
@ -133,6 +132,7 @@ export interface StreamingContent {
backdrop_path?: string; backdrop_path?: string;
}; };
addedToLibraryAt?: number; // Timestamp when added to library addedToLibraryAt?: number; // Timestamp when added to library
addonId?: string; // ID of the addon that provided this content
} }
export interface CatalogContent { export interface CatalogContent {
@ -140,6 +140,7 @@ export interface CatalogContent {
type: string; type: string;
id: string; id: string;
name: string; name: string;
originalName?: string;
genre?: string; genre?: string;
items: StreamingContent[]; items: StreamingContent[];
} }
@ -375,7 +376,7 @@ class CatalogService {
if (metas && metas.length > 0) { if (metas && metas.length > 0) {
// Cap items per catalog to reduce memory and rendering load // Cap items per catalog to reduce memory and rendering load
const limited = metas.slice(0, 12); const limited = metas.slice(0, 12);
const items = limited.map(meta => this.convertMetaToStreamingContent(meta, addon.id)); const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name; if customized, respect it as-is // Get potentially custom display name; if customized, respect it as-is
const originalName = catalog.name || catalog.id; const originalName = catalog.name || catalog.id;
@ -467,7 +468,7 @@ class CatalogService {
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (metas && metas.length > 0) { if (metas && metas.length > 0) {
const items = metas.map(meta => this.convertMetaToStreamingContent(meta, addon.id)); const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
// Get potentially custom display name // Get potentially custom display name
const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name); const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
@ -704,7 +705,7 @@ class CatalogService {
}); });
// Add to recent content using enhanced conversion for full metadata // Add to recent content using enhanced conversion for full metadata
const content = this.convertMetaToStreamingContentEnhanced(meta, preferredAddonId); const content = this.convertMetaToStreamingContentEnhanced(meta);
this.addToRecentContent(content); this.addToRecentContent(content);
// Check if it's in the library // Check if it's in the library
@ -798,7 +799,7 @@ class CatalogService {
if (meta) { if (meta) {
// Use basic conversion without enhanced metadata processing // Use basic conversion without enhanced metadata processing
const content = this.convertMetaToStreamingContent(meta, preferredAddonId); const content = this.convertMetaToStreamingContent(meta);
// Check if it's in the library // Check if it's in the library
content.inLibrary = this.library[`${type}:${id}`] !== undefined; content.inLibrary = this.library[`${type}:${id}`] !== undefined;
@ -817,7 +818,7 @@ class CatalogService {
} }
} }
private convertMetaToStreamingContent(meta: Meta, addonId?: string): StreamingContent { private convertMetaToStreamingContent(meta: Meta): StreamingContent {
// Basic conversion for catalog display - no enhanced metadata processing // Basic conversion for catalog display - no enhanced metadata processing
// Use addon's poster if available, otherwise use placeholder // Use addon's poster if available, otherwise use placeholder
let posterUrl = meta.poster; let posterUrl = meta.poster;
@ -835,7 +836,6 @@ class CatalogService {
id: meta.id, id: meta.id,
type: meta.type, type: meta.type,
name: meta.name, name: meta.name,
addonId,
poster: posterUrl, poster: posterUrl,
posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type
banner: meta.background, banner: meta.background,
@ -852,13 +852,12 @@ class CatalogService {
} }
// Enhanced conversion for detailed metadata (used only when fetching individual content details) // Enhanced conversion for detailed metadata (used only when fetching individual content details)
private convertMetaToStreamingContentEnhanced(meta: Meta, addonId?: string): StreamingContent { private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent {
// Enhanced conversion to utilize all available metadata from addons // Enhanced conversion to utilize all available metadata from addons
const converted: StreamingContent = { const converted: StreamingContent = {
id: meta.id, id: meta.id,
type: meta.type, type: meta.type,
name: meta.name, name: meta.name,
addonId,
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image', poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: meta.posterShape || 'poster', posterShape: meta.posterShape || 'poster',
banner: meta.background, banner: meta.background,
@ -1145,23 +1144,22 @@ class CatalogService {
const supportsGenre = catalog.extra?.some(e => e.name === 'genre') || const supportsGenre = catalog.extra?.some(e => e.name === 'genre') ||
catalog.extraSupported?.includes('genre'); catalog.extraSupported?.includes('genre');
// If genre is specified but not supported, we still fetch but without the filter // If genre is specified, only use catalogs that support genre OR have no filter restrictions
// This ensures we don't skip addons that don't support the filter // If genre is specified but catalog doesn't support genre filter, skip it
if (genre && !supportsGenre) {
continue;
}
const manifest = manifests.find(m => m.id === addon.id); const manifest = manifests.find(m => m.id === addon.id);
if (!manifest) continue; if (!manifest) continue;
const fetchPromise = (async () => { const fetchPromise = (async () => {
try { try {
// Only apply genre filter if supported const filters = genre ? [{ title: 'genre', value: genre }] : [];
const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters); const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (metas && metas.length > 0) { if (metas && metas.length > 0) {
const items = metas.slice(0, limit).map(meta => ({ const items = metas.slice(0, limit).map(meta => this.convertMetaToStreamingContent(meta));
...this.convertMetaToStreamingContent(meta),
addonId: addon.id // Attach addon ID to each result
}));
return { return {
addonName: addon.name, addonName: addon.name,
items items
@ -1206,7 +1204,7 @@ class CatalogService {
* @param catalogId - The catalog ID * @param catalogId - The catalog ID
* @param type - Content type (movie/series) * @param type - Content type (movie/series)
* @param genre - Optional genre filter * @param genre - Optional genre filter
* @param limit - Maximum items to return * @param page - Page number for pagination (default 1)
*/ */
async discoverContentFromCatalog( async discoverContentFromCatalog(
addonId: string, addonId: string,
@ -1224,24 +1222,11 @@ class CatalogService {
return []; return [];
} }
// Find the catalog to check if it supports genre filter const filters = genre ? [{ title: 'genre', value: genre }] : [];
const addon = (await this.getAllAddons()).find(a => a.id === addonId);
const catalog = addon?.catalogs?.find(c => c.id === catalogId);
// Check if catalog supports genre filter
const supportsGenre = catalog?.extra?.some((e: any) => e.name === 'genre') ||
catalog?.extraSupported?.includes('genre');
// Only apply genre filter if the catalog supports it
const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters); const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
if (metas && metas.length > 0) { if (metas && metas.length > 0) {
return metas.map(meta => ({ return metas.map(meta => this.convertMetaToStreamingContent(meta));
...this.convertMetaToStreamingContent(meta),
addonId: addonId
}));
} }
return []; return [];
} catch (error) { } catch (error) {
@ -1534,10 +1519,7 @@ class CatalogService {
const metas = response.data?.metas || []; const metas = response.data?.metas || [];
if (metas.length > 0) { if (metas.length > 0) {
const items = metas.map(meta => ({ const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
...this.convertMetaToStreamingContent(meta),
addonId: addon.id
}));
logger.log(`Found ${items.length} results from ${addon.name}`); logger.log(`Found ${items.length} results from ${addon.name}`);
return items; return items;
} }
@ -1625,4 +1607,4 @@ class CatalogService {
} }
export const catalogService = CatalogService.getInstance(); export const catalogService = CatalogService.getInstance();
export default catalogService; export default catalogService;

View file

@ -38,9 +38,66 @@ export async function getCatalogDisplayName(addonId: string, type: string, catal
return customNames[key] || originalName; return customNames[key] || originalName;
} }
// Function to clear the cache if settings are updated elsewhere
// Function to clear the cache if settings are updated elsewhere // Function to clear the cache if settings are updated elsewhere
export function clearCustomNameCache() { export function clearCustomNameCache() {
customNamesCache = {}; // Reset to empty object customNamesCache = {}; // Reset to empty object
cacheTimestamp = 0; // Invalidate timestamp cacheTimestamp = 0; // Invalidate timestamp
logger.info('Custom catalog name cache cleared.'); logger.info('Custom catalog name cache cleared.');
}
/**
* Formats a catalog name by de-duplicating words, removing redundant English suffixes,
* and appending localized content type
*/
export function getFormattedCatalogName(
originalName: string,
type: string,
localizedMovie: string,
localizedSeries: string,
localizedChannels?: string
): string {
if (!originalName) return '';
// 1. De-duplicate repeated words (case-insensitive)
const words = originalName.split(' ').filter(Boolean);
const uniqueWords: string[] = [];
const seen = new Set<string>();
for (const w of words) {
const lw = w.toLowerCase();
if (!seen.has(lw)) {
uniqueWords.push(w);
seen.add(lw);
}
}
let processedName = uniqueWords.join(' ');
// 2. Remove redundant English suffixes if they exist
const redundantSuffixes = [' movies', ' movie', ' series', ' tv shows', ' tv show', ' shows', ' show', ' channels', ' channel'];
const lowerName = processedName.toLowerCase();
for (const suffix of redundantSuffixes) {
if (lowerName.endsWith(suffix)) {
processedName = processedName.substring(0, processedName.length - suffix.length).trim();
break;
}
}
// 3. Determine the localized content type suffix
let contentType = '';
if (type === 'movie') {
contentType = localizedMovie;
} else if (type === 'series' || type === 'tv') {
contentType = localizedSeries;
} else if (type === 'channel' && localizedChannels) {
contentType = localizedChannels;
}
if (!contentType) return processedName;
// 4. If the processed name already contains the localized content type, return it
if (processedName.toLowerCase().includes(contentType.toLowerCase())) {
return processedName;
}
return `${processedName} ${contentType}`;
} }

View file

@ -1,7 +1,7 @@
// Single source of truth for the app version displayed in Settings // Single source of truth for the app version displayed in Settings
// Update this when bumping app version // Update this when bumping app version
export const APP_VERSION = '1.3.3'; export const APP_VERSION = '1.3.4';
export function getDisplayedAppVersion(): string { export function getDisplayedAppVersion(): string {
return APP_VERSION; return APP_VERSION;