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,
LogBox
} from 'react-native';
import './src/i18n'; // Initialize i18n
import { NavigationContainer } from '@react-navigation/native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { StatusBar } from 'expo-status-bar';

View file

@ -95,8 +95,8 @@ android {
applicationId 'com.nuvio.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 31
versionName "1.3.3"
versionCode 32
versionName "1.3.4"
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]
applicationVariants.all { variant ->
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 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_status_bar_translucent" translatable="false">false</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>

View file

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

100
package-lock.json generated
View file

@ -64,10 +64,14 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"i18next": "^25.7.3",
"intl-pluralrules": "^2.0.1",
"lodash": "^4.17.21",
"lottie-react-native": "~7.3.1",
"posthog-react-native": "^4.4.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.5.1",
"react-native": "0.81.4",
"react-native-boost": "^0.6.2",
"react-native-bottom-tabs": "^1.0.2",
@ -87,7 +91,7 @@
"react-native-svg": "^15.12.1",
"react-native-url-polyfill": "^3.0.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-wheel-color-picker": "^1.3.1",
"react-native-worklets": "^0.7.1"
@ -7505,6 +7509,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"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": {
"version": "3.9.2",
"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==",
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -7743,6 +7787,12 @@
"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": {
"version": "2.2.4",
"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": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
@ -10537,6 +10599,33 @@
"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": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
@ -13250,6 +13339,15 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",

View file

@ -64,10 +64,14 @@
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"i18next": "^25.7.3",
"intl-pluralrules": "^2.0.1",
"lodash": "^4.17.21",
"lottie-react-native": "~7.3.1",
"posthog-react-native": "^4.4.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.5.1",
"react-native": "0.81.4",
"react-native-boost": "^0.6.2",
"react-native-bottom-tabs": "^1.0.2",
@ -87,7 +91,7 @@
"react-native-svg": "^15.12.1",
"react-native-url-polyfill": "^3.0.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-wheel-color-picker": "^1.3.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';
import { InteractionManager } from 'react-native';
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 Animated, { FadeIn } from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
@ -16,7 +17,6 @@ import { useTheme } from '../../contexts/ThemeContext';
const { width } = Dimensions.get('window');
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 weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
interface CalendarEpisode {
id: string;
@ -76,8 +76,19 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
episodes = [],
onSelectDate
}) => {
const { t } = useTranslation();
const { currentTheme } = useTheme();
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 scrollViewRef = useRef<ScrollView>(null);
const [uiReady, setUiReady] = useState(false);

View file

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

View file

@ -1,6 +1,7 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions, FlatList } from 'react-native';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { CatalogContent, StreamingContent } from '../../services/catalogService';
@ -8,6 +9,7 @@ import { useTheme } from '../../contexts/ThemeContext';
import ContentItem from './ContentItem';
import Animated, { FadeIn, Layout } from 'react-native-reanimated';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { getFormattedCatalogName, getCatalogDisplayName } from '../../utils/catalogNameUtils';
interface CatalogSectionProps {
catalog: CatalogContent;
@ -73,9 +75,44 @@ const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const { t, i18n } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
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) => {
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
}, [navigation, catalog.addon]);
@ -117,7 +154,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
]}
numberOfLines={1}
>
{catalog.name}
{displayName}
</Text>
<View
style={[
@ -154,7 +191,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
marginRight: isTV ? 6 : isLargeTablet ? 5 : 4,
}
]}>View All</Text>
]}>{t('home.view_all')}</Text>
<MaterialIcons
name="chevron-right"
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useToast } from '../../contexts/ToastContext';
import { DeviceEventEmitter } 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 ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, deferMs = 0 }: ContentItemProps) => {
const { t } = useTranslation();
// Track inLibrary status locally to force re-render
const [inLibrary, setInLibrary] = useState(!!item.inLibrary);
@ -182,10 +184,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
case 'library':
if (inLibrary) {
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 {
catalogService.addToLibrary(item);
showSuccess('Added to Library', 'Added to your local library');
showSuccess(t('library.added_to_library'), t('library.item_added'));
}
break;
case 'watched': {
@ -194,7 +196,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
try {
await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
} 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(() => {
DeviceEventEmitter.emit('watchedStatusChanged');
}, 100);
@ -240,10 +242,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
case 'trakt-watchlist': {
if (isInWatchlist(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 {
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);
break;
@ -251,10 +253,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
case 'trakt-collection': {
if (isInCollection(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 {
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);
break;

View file

@ -11,7 +11,12 @@ import {
Platform
} from 'react-native';
import { FlatList } from 'react-native';
import { useTranslation } from 'react-i18next';
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 { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@ -26,7 +31,7 @@ import { TraktService } from '../../services/traktService';
import { stremioService } from '../../services/stremioService';
import { streamCacheService } from '../../services/streamCacheService';
import { useSettings } from '../../hooks/useSettings';
import CustomAlert from '../../components/CustomAlert';
// Define interface for continue watching items
interface ContinueWatchingItem extends StreamingContent {
@ -103,9 +108,11 @@ const isEpisodeReleased = (video: any): boolean => {
// Create a proper imperative handle with React.forwardRef and updated type
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const insets = useSafeAreaInsets();
const [continueWatchingItems, setContinueWatchingItems] = useState<ContinueWatchingItem[]>([]);
const [loading, setLoading] = useState(true);
const appState = useRef(AppState.currentState);
@ -113,6 +120,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const [deletingItemId, setDeletingItemId] = useState<string | 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
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
const deviceWidth = dimensions.width;
@ -195,11 +206,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
}, [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
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
const validItems: ContinueWatchingItem[] = [];
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
if (recentlyRemovedRef.current.has(key)) {
continue;
}
// Skip persistently removed items
const isRemoved = await storageService.isContinueWatchingRemoved(it.id, it.type);
// Skip persistently removed items (episode-specific for series)
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) {
continue;
}
@ -511,8 +524,54 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const { episodeId, progress, progressPercent } = episode;
if (group.type === 'series' && progressPercent >= 85) {
// Skip completed episodes - don't add "next episode" here
// The Trakt playback endpoint handles in-progress items
// Episode is completed - find the next unwatched episode
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;
}
@ -627,13 +686,14 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
try {
// Skip items with < 2% progress (accidental clicks)
if (item.progress < 2) continue;
// Skip items with >= 85% progress (completed)
if (item.progress >= 85) continue;
// Skip items older than 30 days
const pausedAt = new Date(item.paused_at).getTime();
if (pausedAt < thirtyDaysAgo) continue;
if (item.type === 'movie' && item.movie?.ids?.imdb) {
// Skip completed movies
if (item.progress >= 85) continue;
const imdbId = item.movie.ids.imdb.startsWith('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);
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({
...cachedData.basicContent,
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)
if (traktBatch.length > 0) {
// 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());
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
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);
}
} catch (err) {
@ -936,71 +1128,121 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
}, [navigation, settings.useCachedStreams, settings.openMetadataScreenWhenCacheDisabled]);
// Handle long press to delete (moved before renderContinueWatchingItem)
const handleLongPress = useCallback(async (item: ContinueWatchingItem) => {
// Handle long press to show action sheet
const handleLongPress = useCallback((item: ContinueWatchingItem) => {
try {
// Trigger haptic feedback
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} catch (error) {
// Ignore haptic errors
}
setSelectedItem(item);
actionSheetRef.current?.present();
}, []);
const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated();
// Handle view details action
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) {
setAlertMessage(`Remove "${item.name}" from your continue watching list?\n\nThis will also remove it from your Trakt Continue Watching.`);
} else {
setAlertMessage(`Remove "${item.name}" from your continue watching list?`);
// Handle remove action
const handleRemoveItem = useCallback(async () => {
if (!selectedItem) return;
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([
{
label: 'Cancel',
style: { color: '#888' },
onPress: () => { },
},
{
label: 'Remove',
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]);
// Render backdrop for bottom sheet
const renderBackdrop = useCallback(
(props: any) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.6}
/>
),
[]
);
// Compute poster dimensions for poster-style cards
const computedPosterWidth = useMemo(() => {
@ -1070,7 +1312,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{/* Up Next Badge */}
{item.type === 'series' && item.progress === 0 && (
<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>
)}
@ -1201,7 +1443,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
<Text style={[
styles.progressText,
{ fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12 }
]}>Up Next</Text>
]}>{t('home.up_next')}</Text>
</View>
)}
</View>
@ -1220,7 +1462,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 13
}
]}>
Season {item.season}
{t('home.season', { season: item.season })}
</Text>
{item.episodeTitle && (
<Text
@ -1247,7 +1489,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
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>
);
}
@ -1279,7 +1521,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 11
}
]}>
{Math.round(item.progress)}% watched
{t('home.percent_watched', { percent: Math.round(item.progress) })}
</Text>
</View>
)}
@ -1318,7 +1560,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
color: currentTheme.colors.text,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
}
]}>Continue Watching</Text>
]}>{t('home.continue_watching')}</Text>
<View style={[
styles.titleUnderline,
{
@ -1349,13 +1591,101 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
removeClippedSubviews={true}
/>
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
{/* Action Sheet Bottom Sheet */}
<BottomSheetModal
ref={actionSheetRef}
index={0}
snapPoints={['35%']}
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>
);
});
@ -1630,6 +1960,74 @@ const styles = StyleSheet.create({
fontWeight: '500',
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) => {

View file

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

View file

@ -13,6 +13,7 @@ import {
Platform
} from 'react-native';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient';
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 NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
@ -103,11 +105,11 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
return (
<View style={styles.noContentContainer}>
<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}>
{onRetry
? 'There was a problem fetching featured content. Please check your connection and try again.'
: 'Install addons with catalogs or change the content source in your settings.'}
? t('home.load_error_desc')
: t('home.no_featured_desc')}
</Text>
<View style={styles.noContentButtons}>
{onRetry ? (
@ -115,7 +117,7 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
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>
) : (
<>
@ -123,13 +125,13 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
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
style={styles.noContentButton}
onPress={() => navigation.navigate('HomeScreenSettings')}
>
<Text style={styles.noContentButtonText}>Settings</Text>
<Text style={styles.noContentButtonText}>{t('home.settings')}</Text>
</TouchableOpacity>
</>
)}
@ -139,6 +141,7 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
};
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loading, onRetry }: FeaturedContentProps) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
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} />
<Text style={[styles.tabletPlayButtonText as TextStyle, { color: currentTheme.colors.black }]}>
Play Now
{t('home.play_now')}
</Text>
</TouchableOpacity>
@ -520,7 +523,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
>
<MaterialIcons name={isSaved ? "bookmark" : "bookmark-outline"} size={20} 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>
</TouchableOpacity>
@ -531,7 +534,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
>
<MaterialIcons name="info-outline" size={20} color={currentTheme.colors.white} />
<Text style={[styles.tabletSecondaryButtonText as TextStyle, { color: currentTheme.colors.white }]}>
More Info
{t('home.more_info')}
</Text>
</TouchableOpacity>
</Animated.View>
@ -626,7 +629,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
>
<MaterialIcons name={isSaved ? "bookmark" : "bookmark-outline"} size={24} 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>
</TouchableOpacity>
@ -644,7 +647,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
>
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
Play
{t('home.play')}
</Text>
</TouchableOpacity>
@ -655,7 +658,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
>
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
Info
{t('home.info')}
</Text>
</TouchableOpacity>
</Animated.View>

View file

@ -1,5 +1,6 @@
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 { useTranslation } from 'react-i18next';
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 { BlurView } from 'expo-blur';
@ -38,6 +39,7 @@ interface HeroCarouselProps {
const TOP_TABS_OFFSET = Platform.OS === 'ios' ? 44 : 48;
const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
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 { t } = useTranslation();
const [bannerLoaded, setBannerLoaded] = useState(false);
const [logoLoaded, setLogoLoaded] = useState(false);
@ -847,7 +850,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
textShadowRadius: 2,
}
]}>
{item.description || 'No description available'}
{item.description || t('home.no_description')}
</Text>
</ScrollView>
</View>
@ -956,7 +959,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
textShadowRadius: 2,
}
]}>
{item.description || 'No description available'}
{item.description || t('home.no_description')}
</Text>
</ScrollView>
</View>

View file

@ -9,6 +9,7 @@ import {
Dimensions
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { NavigationProp } from '@react-navigation/native';
import FastImage from '@d11/react-native-fast-image';
import { LinearGradient } from 'expo-linear-gradient';
@ -58,6 +59,7 @@ interface ThisWeekEpisode {
}
export const ThisWeekSection = React.memo(() => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { calendarData, loading } = useCalendarData();
@ -176,7 +178,7 @@ export const ThisWeekSection = React.memo(() => {
processedItems.push({
...firstEp,
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,
isGroup: true,
episodeCount: group.length,
@ -239,7 +241,7 @@ export const ThisWeekSection = React.memo(() => {
const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => {
// Handle episodes without release dates gracefully
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;
// 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]}
>
<View style={styles.cardHeader}>
<View style={[
<View style={[
styles.statusBadge,
{ backgroundColor: isReleased ? currentTheme.colors.primary : 'rgba(0,0,0,0.6)' }
]}>
<Text style={styles.statusText}>
{isReleased ? (item.isGroup ? 'Released' : 'New') : formattedDate}
{isReleased ? (item.isGroup ? t('home.released') : t('home.new')) : formattedDate}
</Text>
</View>
</View>
@ -357,7 +359,7 @@ export const ThisWeekSection = React.memo(() => {
color: currentTheme.colors.text,
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
}
]}>This Week</Text>
]}>{t('home.this_week')}</Text>
<View style={[
styles.titleUnderline,
{
@ -380,7 +382,7 @@ export const ThisWeekSection = React.memo(() => {
color: currentTheme.colors.textMuted,
fontSize: isTV ? 18 : isLargeTablet ? 16 : isTablet ? 15 : 14
}
]}>View All</Text>
]}>{t('home.view_all')}</Text>
<MaterialIcons
name="chevron-right"
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}

View file

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

View file

@ -8,6 +8,7 @@ import {
ActivityIndicator,
Dimensions,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import FastImage from '@d11/react-native-fast-image';
import Animated, {
FadeIn,
@ -35,6 +36,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
onSelectCastMember,
isTmdbEnrichmentEnabled = true,
}) => {
const { t } = useTranslation();
const { currentTheme } = useTheme();
// 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,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>Cast</Text>
]}>{t('metadata.cast')}</Text>
</View>
<FlatList
horizontal

View file

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

View file

@ -12,6 +12,7 @@ import {
Animated,
Linking,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons';
import TraktIcon from '../../../assets/rating-icons/trakt.svg';
import { useTheme } from '../../contexts/ThemeContext';
@ -186,6 +187,7 @@ const CompactCommentCard: React.FC<{
isSpoilerRevealed: boolean;
onSpoilerPress: () => void;
}> = ({ comment, theme, onPress, isSpoilerRevealed, onSpoilerPress }) => {
const { t } = useTranslation();
const [isPressed, setIsPressed] = useState(false);
const fadeInOpacity = useRef(new Animated.Value(0)).current;
@ -262,7 +264,7 @@ const CompactCommentCard: React.FC<{
// Handle missing user data gracefully
const user = comment.user || {};
const username = user.name || user.username || 'Anonymous';
const username = user.name || user.username || t('common.anonymous_user');
// Handle spoiler content
const hasSpoiler = comment.spoiler;
@ -280,10 +282,10 @@ const CompactCommentCard: React.FC<{
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMins < 1) return 'now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffMins < 1) return t('common.time.now');
if (diffMins < 60) return t('common.time.minutes_ago', { count: diffMins });
if (diffHours < 24) return t('common.time.hours_ago', { count: diffHours });
if (diffDays < 7) return t('common.time.days_ago', { count: diffDays });
// For older dates, show month/day
return commentDate.toLocaleDateString('en-US', {
@ -725,6 +727,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
episode,
onCommentPress,
}) => {
const { t } = useTranslation();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const [hasLoadedOnce, setHasLoadedOnce] = React.useState(false);
@ -823,12 +826,12 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
<View style={styles.emptyContainer}>
<MaterialIcons name="chat-bubble-outline" size={48} 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 style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
{error
? 'This content may not be in Trakt\'s database yet'
: 'Be the first to comment on Trakt.tv'
? t('comments.not_in_database')
: t('comments.check_trakt')
}
</Text>
</View>
@ -930,7 +933,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
}
]}>
Trakt Comments
{t('comments.title')}
</Text>
</View>
@ -945,7 +948,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
onPress={refresh}
>
<Text style={[styles.retryButtonText, { color: currentTheme.colors.error }]}>
Retry
{t('common.retry')}
</Text>
</TouchableOpacity>
</View>
@ -993,7 +996,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
) : (
<>
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
Load More
{t('common.load_more')}
</Text>
<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 { useSettings } from '../../hooks/useSettings';
import { useTrailer } from '../../contexts/TrailerContext';
import { useTranslation } from 'react-i18next';
import { logger } from '../../utils/logger';
import { TMDBService } from '../../services/tmdbService';
import TrailerService from '../../services/trailerService';
@ -149,6 +150,7 @@ const ActionButtons = memo(({
onToggleCollection?: () => void;
}) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast();
// Performance optimization: Cache theme colors
@ -235,9 +237,9 @@ const ActionButtons = memo(({
// Show appropriate toast
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 {
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]);
@ -263,7 +265,7 @@ const ActionButtons = memo(({
const finalPlayButtonText = useMemo(() => {
// For movies, handle watched state
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
@ -306,7 +308,7 @@ const ActionButtons = memo(({
return `Play S${seasonStr}E${episodeStr}`;
} else {
// If next episode doesn't exist, show generic text
return 'Completed';
return t('metadata.completed');
}
} else {
// For non-watched episodes, check if current episode exists
@ -320,17 +322,17 @@ const ActionButtons = memo(({
return playButtonText;
} else {
// Current episode doesn't exist, fallback to generic play
return 'Play';
return t('metadata.play');
}
}
}
// 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
return isWatched ? 'Play' : playButtonText;
return isWatched ? t('metadata.play') : playButtonText;
}, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]);
// 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}
/>
<Text style={[styles.infoButtonText, isTablet && styles.tabletInfoButtonText]}>
{inLibrary ? 'Saved' : 'Save'}
{inLibrary ? t('metadata.saved') : t('metadata.save')}
</Text>
</TouchableOpacity>
@ -484,6 +486,7 @@ const WatchProgressDisplay = memo(({
trailerReady: boolean;
}) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext();
// State to trigger refresh after manual sync
@ -567,7 +570,7 @@ const WatchProgressDisplay = memo(({
progressPercent: 100,
formattedTime: watchedDate,
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
isTraktSynced: watchProgress?.traktSynced && isTraktAuthenticated,
isWatched: true
@ -597,22 +600,22 @@ const WatchProgressDisplay = memo(({
}
// 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 = '';
// Show Trakt sync status if user is authenticated
if (isTraktAuthenticated) {
if (isUsingTraktProgress) {
syncStatus = ' • Using Trakt progress';
syncStatus = ' • ' + t('metadata.using_trakt_progress');
if (watchProgress.traktSynced) {
syncStatus = ' • Synced with Trakt';
syncStatus = ' • ' + t('metadata.synced_with_trakt_progress');
}
} 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 (watchProgress.traktProgress !== undefined &&
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 {
// Do not show "Sync pending" label anymore; leave status empty.

View file

@ -9,6 +9,7 @@ import {
Dimensions,
Platform,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import FastImage from '@d11/react-native-fast-image';
import { useNavigation, StackActions } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
@ -39,6 +40,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
recommendations,
loadingRecommendations
}) => {
const { t } = useTranslation();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -112,9 +114,9 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
}
} catch (error) {
if (__DEV__) console.error('Error navigating to recommendation:', error);
setAlertTitle('Error');
setAlertMessage('Unable to load this content. Please try again later.');
setAlertActions([{ label: 'OK', onPress: () => { } }]);
setAlertTitle(t('common.error'));
setAlertMessage(t('metadata.something_went_wrong'));
setAlertActions([{ label: t('common.ok'), onPress: () => { } }]);
setAlertVisible(true);
}
};
@ -149,7 +151,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
return (
<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
data={recommendations}
renderItem={renderItem}

View file

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

View file

@ -10,6 +10,7 @@ import {
Platform,
Alert,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../../contexts/ThemeContext';
import { useTrailer } from '../../contexts/TrailerContext';
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 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 {
id: string;
key: string;
@ -61,8 +44,28 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
trailer,
contentTitle
}) => {
const { t } = useTranslation();
const { currentTheme } = useTheme();
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 [trailerUrl, setTrailerUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@ -126,9 +129,9 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
logger.error('TrailerModal', 'Error loading trailer:', err);
Alert.alert(
'Trailer Unavailable',
'This trailer could not be loaded at this time. Please try again later.',
[{ text: 'OK', style: 'default' }]
t('trailers.unavailable'),
t('trailers.unavailable_desc'),
[{ text: t('common.ok'), style: 'default' }]
);
}
}, [trailer, contentTitle, pauseTrailer]);
@ -232,7 +235,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
hitSlop={{ top: 10, left: 10, right: 10, bottom: 10 }}
>
<Text style={[styles.closeButtonText, { color: currentTheme.colors.highEmphasis }]}>
Close
{t('common.close')}
</Text>
</TouchableOpacity>
</View>
@ -257,7 +260,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={loadTrailer}
>
<Text style={styles.retryButtonText}>Try Again</Text>
<Text style={styles.retryButtonText}>{t('common.try_again')}</Text>
</TouchableOpacity>
</View>
)}

View file

@ -11,6 +11,7 @@ import {
ScrollView,
Modal,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { MaterialIcons } from '@expo/vector-icons';
import FastImage from '@d11/react-native-fast-image';
import { useTheme } from '../../contexts/ThemeContext';
@ -59,6 +60,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
contentId,
contentTitle
}) => {
const { t } = useTranslation();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { pauseTrailer } = useTrailer();
@ -414,22 +416,22 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
};
// Format trailer type for display
const formatTrailerType = (type: string): string => {
const formatTrailerType = useCallback((type: string): string => {
switch (type) {
case 'Trailer':
return 'Official Trailers';
return t('trailers.official_trailers');
case 'Teaser':
return 'Teasers';
return t('trailers.teasers');
case 'Clip':
return 'Clips & Scenes';
return t('trailers.clips_scenes');
case 'Featurette':
return 'Featurettes';
return t('trailers.featurettes');
case 'Behind the Scenes':
return 'Behind the Scenes';
return t('trailers.behind_the_scenes');
default:
return type;
}
};
}, [t]);
// Get icon for trailer type
const getTrailerTypeIcon = (type: string): string => {
@ -483,12 +485,12 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
<View style={styles.header}>
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
Trailers
{t('trailers.title')}
</Text>
</View>
<View style={styles.noTrailersContainer}>
<Text style={[styles.noTrailersText, { color: currentTheme.colors.textMuted }]}>
No trailers available
{t('trailers.no_trailers')}
</Text>
</View>
</View>
@ -512,7 +514,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
}
]}>
Trailers & Videos
{t('trailers.title')}
</Text>
{/* 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 { LinearGradient } from 'expo-linear-gradient';
import Slider from '@react-native-community/slider';
import { useTranslation } from 'react-i18next';
import { styles } from '../utils/playerStyles'; // Updated styles
import { getTrackDisplayName } from '../utils/playerUtils';
import { useTheme } from '../../../contexts/ThemeContext';
@ -99,6 +100,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
useExoPlayer,
}) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
/* Responsive Spacing */
@ -287,7 +289,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
}}
minimumValue={0}
maximumValue={duration || 1}
value={previewTime}
onValueChange={(v) => setPreviewTime(v)}
@ -338,7 +340,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
{/* Show year and provider (quality chip removed) */}
<View style={styles.metadataRow}>
{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>
{playerBackend && (
<View style={styles.metadataRow}>

View file

@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, useWindowDimensions, StyleSheet, Platform } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Animated, {
FadeIn,
FadeOut,
@ -25,6 +26,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
selectedAudioTrack,
selectAudioTrack,
}) => {
const { t } = useTranslation();
const { width, height } = useWindowDimensions();
// Size constants matching SubtitleModal aesthetics
@ -67,7 +69,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
>
{/* Header with shared aesthetics */}
<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>
<ScrollView
@ -111,7 +113,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
{ksAudioTracks.length === 0 && (
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<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 file

@ -7,6 +7,7 @@ import Animated, {
SlideInRight,
SlideOutRight,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { Episode } from '../../../types/metadata';
import { Stream } from '../../../types/streams';
import { stremioService } from '../../../services/stremioService';
@ -58,6 +59,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
onSelectStream,
metadata,
}) => {
const { t } = useTranslation();
const { width } = useWindowDimensions();
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={{ flex: 1, marginRight: 10 }}>
<Text style={{ color: 'white', fontSize: 20, fontWeight: '700' }} numberOfLines={1}>
{episode?.name || 'Sources'}
{episode?.name || t('player_ui.sources')}
</Text>
{episode && (
<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 && (
<View style={{ padding: 40, alignItems: 'center' }}>
<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>
)}
@ -237,7 +239,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
<Text style={{ color: 'white', fontWeight: '700', fontSize: 14, flex: 1 }} numberOfLines={1}>
{stream.name || 'Unknown Source'}
{stream.name || t('player_ui.unknown_source')}
</Text>
<QualityBadge quality={quality} />
</View>
@ -258,13 +260,13 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
{!isLoading && sortedProviders.length === 0 && (
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<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>
)}
{hasErrors.length > 0 && (
<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>
)}
</ScrollView>

View file

@ -7,6 +7,7 @@ import Animated, {
SlideInRight,
SlideOutRight,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { Episode } from '../../../types/metadata';
import { EpisodeCard } from '../cards/EpisodeCard';
import { storageService } from '../../../services/storageService';
@ -32,6 +33,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
onSelectEpisode,
tmdbEpisodeOverrides
}) => {
const { t } = useTranslation();
const { width } = useWindowDimensions();
const [selectedSeason, setSelectedSeason] = useState<number>(currentEpisode?.season || 1);
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={{ 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>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 15, gap: 8 }}>
@ -143,7 +145,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
color: selectedSeason === season ? 'black' : 'white',
fontWeight: selectedSeason === season ? '700' : '500'
}}>
{season === 0 ? 'Specials' : `Season ${season}`}
{season === 0 ? t('player_ui.specials') : t('player_ui.season', { season })}
</Text>
</TouchableOpacity>
))}

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import React from 'react';
import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Animated, {
FadeIn,
FadeOut,
@ -55,6 +56,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
holdToSpeedValue,
setHoldToSpeedValue,
}) => {
const { t } = useTranslation();
const { width } = useWindowDimensions();
const speedPresets = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5];
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' }}>
<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>
{/* Speed Selection Row */}
@ -108,7 +110,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
onPress={() => setHoldToSpeedEnabled(!holdToSpeedEnabled)}
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={{
width: 34, height: 18, borderRadius: 10,
backgroundColor: holdToSpeedEnabled ? 'white' : 'rgba(255,255,255,0.2)',

View file

@ -9,6 +9,7 @@ import Animated, {
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
import { useTranslation } from 'react-i18next';
import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes';
import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils';
@ -96,6 +97,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
selectedExternalSubtitleId,
onOpenSyncModal,
}) => {
const { t } = useTranslation();
const { width, height } = useWindowDimensions();
const isIos = Platform.OS === 'ios';
const isLandscape = width > height;
@ -151,14 +153,14 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
>
{/* Header */}
<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>
{/* Tab Bar */}
<View style={{ flexDirection: 'row', gap: 15, paddingHorizontal: 70, marginBottom: 20 }}>
<MorphingTab label="Built-in" isSelected={activeTab === 'built-in'} onPress={() => setActiveTab('built-in')} />
<MorphingTab label="Addons" isSelected={activeTab === 'addon'} onPress={() => setActiveTab('addon')} />
<MorphingTab label="Style" isSelected={activeTab === 'appearance'} onPress={() => setActiveTab('appearance')} />
<MorphingTab label={t('player_ui.built_in')} isSelected={activeTab === 'built-in'} onPress={() => setActiveTab('built-in')} />
<MorphingTab label={t('player_ui.addons')} isSelected={activeTab === 'addon'} onPress={() => setActiveTab('addon')} />
<MorphingTab label={t('player_ui.style')} isSelected={activeTab === 'appearance'} onPress={() => setActiveTab('appearance')} />
</View>
<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)' }}
>
<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>
{ksTextTracks.map((track) => (
<TouchableOpacity
@ -199,7 +201,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{availableSubtitles.length === 0 ? (
<TouchableOpacity onPress={fetchAvailableSubtitles} style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<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>
) : (
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={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}>
<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 style={{ height: previewHeight, justifyContent: 'flex-end' }}>
<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={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}>
<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 style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<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)' }}
>
<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
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)' }}
>
<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
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)' }}
>
<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
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)' }}
>
<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>
</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={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}>
<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 style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<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 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' }}>
@ -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' }}>
<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>
<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 }}
@ -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={{ flexDirection: 'row', alignItems: 'center' }}>
<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>
{/* Text Color - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<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 style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' }}>
{['#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 */}
{!isExoPlayerInternal && (
<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 }}>
{([{ 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)' }}>
@ -378,7 +380,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
)}
<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' }}>
<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} />
@ -394,7 +396,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{/* Background Opacity - Not supported on ExoPlayer internal subtitles */}
{!isExoPlayerInternal && (
<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' }}>
<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} />
@ -410,16 +412,16 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
)}
{!isUsingInternalSubtitle && (
<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' }}>
<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>
</View>
)}
{!isUsingInternalSubtitle && (
<>
<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 }}>
{['#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)' }} />
@ -427,7 +429,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
</View>
<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' }}>
<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} />
@ -445,7 +447,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{!isUsingInternalSubtitle && (
<View style={{ flexDirection: isCompact ? 'column' : 'row', justifyContent: 'space-between', gap: 12 }}>
<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' }}>
<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} />
@ -459,7 +461,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
</View>
<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' }}>
<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} />
@ -478,7 +480,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{!isExoPlayerInternal && (
<View style={{ marginTop: 4 }}>
<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' }}>
<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} />
@ -511,10 +513,10 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}}
>
<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>
)}
<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 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)' }}
>
<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>
</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
export * from './searchUtils';
export { searchStyles } from './searchStyles';
export { SearchSkeletonLoader } from './SearchSkeletonLoader';
export { SearchAnimation } from './SearchAnimation';
export { SearchResultItem } from './SearchResultItem';
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 { PostHogProvider } from 'posthog-react-native';
import { ScrollToTopProvider, useScrollToTopEmitter } from '../contexts/ScrollToTopContext';
import { useTranslation } from 'react-i18next';
// Optional iOS Glass effect (expo-glass-effect) with safe fallback
let GlassViewComp: any = null;
@ -545,6 +546,7 @@ const WrappedScreen: React.FC<{ Screen: React.ComponentType<any> }> = ({ Screen
// Tab Navigator
const MainTabs = () => {
const { t } = useTranslation();
const { currentTheme } = useTheme();
const { settings } = require('../hooks/useSettings');
const { useSettings: useSettingsHook } = require('../hooks/useSettings');
@ -915,7 +917,7 @@ const MainTabs = () => {
name="Home"
component={HomeScreen}
options={{
title: 'Home',
title: t('navigation.home'),
tabBarIcon: () => ({ sfSymbol: 'house' }),
freezeOnBlur: true,
}}
@ -931,7 +933,7 @@ const MainTabs = () => {
name="Library"
component={LibraryScreen}
options={{
title: 'Library',
title: t('navigation.library'),
tabBarIcon: () => ({ sfSymbol: 'heart' }),
}}
listeners={({ navigation }: { navigation: any }) => ({
@ -946,7 +948,7 @@ const MainTabs = () => {
name="Search"
component={SearchScreen}
options={{
title: 'Search',
title: t('navigation.search'),
tabBarIcon: () => ({ sfSymbol: 'magnifyingglass' }),
}}
listeners={({ navigation }: { navigation: any }) => ({
@ -962,7 +964,7 @@ const MainTabs = () => {
name="Downloads"
component={DownloadsScreen}
options={{
title: 'Downloads',
title: t('navigation.downloads'),
tabBarIcon: () => ({ sfSymbol: 'arrow.down.circle' }),
}}
listeners={({ navigation }: { navigation: any }) => ({
@ -978,7 +980,7 @@ const MainTabs = () => {
name="Settings"
component={SettingsScreen}
options={{
title: 'Settings',
title: t('navigation.settings'),
tabBarIcon: () => ({ sfSymbol: 'gear' }),
}}
listeners={({ navigation }: { navigation: any }) => ({
@ -1053,7 +1055,7 @@ const MainTabs = () => {
name="Home"
component={HomeScreen}
options={{
tabBarLabel: 'Home',
tabBarLabel: t('navigation.home'),
tabBarIcon: ({ color, size, focused }) => (
<MaterialCommunityIcons name={focused ? 'home' : 'home-outline'} size={size} color={color} />
),
@ -1064,7 +1066,7 @@ const MainTabs = () => {
name="Library"
component={LibraryScreen}
options={{
tabBarLabel: 'Library',
tabBarLabel: t('navigation.library'),
tabBarIcon: ({ color, size, focused }) => (
<MaterialCommunityIcons name={focused ? 'heart' : 'heart-outline'} size={size} color={color} />
),
@ -1074,7 +1076,7 @@ const MainTabs = () => {
name="Search"
component={SearchScreen}
options={{
tabBarLabel: 'Search',
tabBarLabel: t('navigation.search'),
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons name={'magnify'} size={size} color={color} />
),
@ -1085,7 +1087,7 @@ const MainTabs = () => {
name="Downloads"
component={DownloadsScreen}
options={{
tabBarLabel: 'Downloads',
tabBarLabel: t('navigation.downloads'),
tabBarIcon: ({ color, size, focused }) => (
<MaterialCommunityIcons name={focused ? 'download' : 'download-outline'} size={size} color={color} />
),
@ -1096,7 +1098,7 @@ const MainTabs = () => {
name="Settings"
component={SettingsScreen}
options={{
tabBarLabel: 'Settings',
tabBarLabel: t('navigation.settings'),
tabBarIcon: ({ color, size, focused }) => (
<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 { useSettings } from '../hooks/useSettings';
import { SvgXml } from 'react-native-svg';
import { useTranslation } from 'react-i18next';
const { width } = Dimensions.get('window');
const isTablet = width >= 768;
const AISettingsScreen: React.FC = () => {
const { t } = useTranslation();
// CustomAlert state (must be inside the component)
const [alertVisible, setAlertVisible] = useState(false);
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"/>
</g>
</svg>`;
const [apiKey, setApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [isKeySet, setIsKeySet] = useState(false);
@ -92,12 +94,12 @@ const AISettingsScreen: React.FC = () => {
const handleSaveApiKey = async () => {
if (!apiKey.trim()) {
openAlert('Error', 'Please enter a valid API key');
openAlert(t('common.error'), t('ai_settings.error_invalid_key'));
return;
}
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;
}
@ -105,9 +107,9 @@ const AISettingsScreen: React.FC = () => {
try {
await mmkvStorage.setItem('openrouter_api_key', apiKey.trim());
setIsKeySet(true);
openAlert('Success', 'OpenRouter API key saved successfully!');
openAlert(t('common.success'), t('ai_settings.success_saved'));
} 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);
} finally {
setLoading(false);
@ -116,10 +118,10 @@ const AISettingsScreen: React.FC = () => {
const handleRemoveApiKey = () => {
openAlert(
'Remove API Key',
'Are you sure you want to remove your OpenRouter API key? This will disable AI chat features.',
t('ai_settings.confirm_remove_title'),
t('ai_settings.confirm_remove_msg'),
[
{ label: 'Cancel', onPress: () => {} },
{ label: t('common.cancel'), onPress: () => { } },
{
label: 'Remove',
onPress: async () => {
@ -127,9 +129,9 @@ const AISettingsScreen: React.FC = () => {
await mmkvStorage.removeItem('openrouter_api_key');
setApiKey('');
setIsKeySet(false);
openAlert('Success', 'API key removed successfully');
openAlert(t('common.success'), t('ai_settings.success_removed'));
} 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 (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
<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 }]}>
Settings
{t('settings.settings_title')}
</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
{/* Empty for now, but ready for future actions */}
</View>
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
AI Assistant
{t('ai_settings.title')}
</Text>
<ScrollView
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
@ -178,42 +180,42 @@ const AISettingsScreen: React.FC = () => {
{/* Info Card */}
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.infoHeader}>
<MaterialIcons
name="smart-toy"
size={24}
<MaterialIcons
name="smart-toy"
size={24}
color={currentTheme.colors.primary}
/>
<Text style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}>
AI-Powered Chat
{t('ai_settings.info_title')}
</Text>
</View>
<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>
<View style={styles.featureList}>
<View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Episode-specific context and analysis
{t('ai_settings.feature_1')}
</Text>
</View>
<View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Plot explanations and character insights
{t('ai_settings.feature_2')}
</Text>
</View>
<View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Behind-the-scenes trivia and facts
{t('ai_settings.feature_3')}
</Text>
</View>
<View style={styles.featureItem}>
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
Your own free OpenRouter API key
{t('ai_settings.feature_4')}
</Text>
</View>
</View>
@ -222,21 +224,21 @@ const AISettingsScreen: React.FC = () => {
{/* API Key Configuration */}
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
OPENROUTER API KEY
{t('ai_settings.api_key_section')}
</Text>
<View style={styles.apiKeySection}>
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
API Key
{t('ai_settings.api_key_label')}
</Text>
<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>
<TextInput
style={[
styles.input,
{
{
backgroundColor: currentTheme.colors.elevation2,
color: currentTheme.colors.highEmphasis,
borderColor: currentTheme.colors.elevation2
@ -258,14 +260,14 @@ const AISettingsScreen: React.FC = () => {
onPress={handleSaveApiKey}
disabled={loading}
>
<MaterialIcons
name="save"
size={20}
<MaterialIcons
name="save"
size={20}
color={currentTheme.colors.white}
style={{ marginRight: 8 }}
/>
<Text style={styles.saveButtonText}>
{loading ? 'Saving...' : 'Save API Key'}
{loading ? t('ai_settings.saving') : t('ai_settings.save_api_key')}
</Text>
</TouchableOpacity>
) : (
@ -275,27 +277,27 @@ const AISettingsScreen: React.FC = () => {
onPress={handleSaveApiKey}
disabled={loading}
>
<MaterialIcons
name="update"
size={20}
<MaterialIcons
name="update"
size={20}
color={currentTheme.colors.white}
style={{ marginRight: 8 }}
/>
<Text style={styles.updateButtonText}>Update</Text>
<Text style={styles.updateButtonText}>{t('ai_settings.update')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.removeButton, { borderColor: currentTheme.colors.error }]}
onPress={handleRemoveApiKey}
>
<MaterialIcons
name="delete"
size={20}
<MaterialIcons
name="delete"
size={20}
color={currentTheme.colors.error}
style={{ marginRight: 8 }}
/>
<Text style={[styles.removeButtonText, { color: currentTheme.colors.error }]}>
Remove
{t('ai_settings.remove')}
</Text>
</TouchableOpacity>
</View>
@ -306,23 +308,23 @@ const AISettingsScreen: React.FC = () => {
style={[styles.getKeyButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={handleGetApiKey}
>
<MaterialIcons
name="open-in-new"
size={20}
<MaterialIcons
name="open-in-new"
size={20}
color={currentTheme.colors.primary}
style={{ marginRight: 8 }}
/>
<Text style={[styles.getKeyButtonText, { color: currentTheme.colors.primary }]}>
Get Free API Key from OpenRouter
{t('ai_settings.get_free_key')}
</Text>
</TouchableOpacity>
</View>
</View>
{/* 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' }}>
<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
value={!!settings.aiChatEnabled}
onValueChange={(v) => updateSetting('aiChatEnabled', v)}
@ -331,24 +333,24 @@ const AISettingsScreen: React.FC = () => {
ios_backgroundColor={currentTheme.colors.elevation2}
/>
</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>
{/* Status Card */}
{isKeySet && (
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.statusHeader}>
<MaterialIcons
name="check-circle"
size={24}
<MaterialIcons
name="check-circle"
size={24}
color={currentTheme.colors.success || '#4CAF50'}
/>
<Text style={[styles.statusTitle, { color: currentTheme.colors.success || '#4CAF50' }]}>
AI Chat Enabled
{t('ai_settings.chat_enabled')}
</Text>
</View>
<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>
</View>
)}
@ -356,14 +358,10 @@ const AISettingsScreen: React.FC = () => {
{/* Usage Info */}
<View style={[styles.usageCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.usageTitle, { color: currentTheme.colors.highEmphasis }]}>
How it works
{t('ai_settings.how_it_works')}
</Text>
<Text style={[styles.usageText, { color: currentTheme.colors.mediumEmphasis }]}>
OpenRouter provides access to multiple AI models{'\n'}
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
{t('ai_settings.how_it_works_desc')}
</Text>
</View>
{/* OpenRouter branding */}

View file

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

View file

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

View file

@ -16,6 +16,7 @@ import {
import { InteractionManager } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import FastImage from '@d11/react-native-fast-image';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@ -55,6 +56,7 @@ interface CalendarSection {
}
const CalendarScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { libraryItems, loading: libraryLoading } = useLibrary();
const { currentTheme } = useTheme();
@ -189,7 +191,7 @@ const CalendarScreen = () => {
) : (
<>
<Text style={[styles.noEpisodesText, { color: currentTheme.colors.text }]}>
No scheduled episodes
{t('calendar.no_scheduled_episodes')}
</Text>
<View style={styles.dateContainer}>
<MaterialIcons
@ -197,7 +199,7 @@ const CalendarScreen = () => {
size={16}
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>
</>
)}
@ -207,16 +209,28 @@ const CalendarScreen = () => {
);
};
const renderSectionHeader = ({ section }: { section: CalendarSection }) => (
<View style={[styles.sectionHeader, {
backgroundColor: currentTheme.colors.darkBackground,
borderBottomColor: currentTheme.colors.border
}]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>
{section.title}
</Text>
</View>
);
const renderSectionHeader = ({ section }: { section: CalendarSection }) => {
// Map section titles to translation keys
const titleKeyMap: Record<string, string> = {
'This Week': 'home.this_week',
'Upcoming': 'home.upcoming',
'Recently Released': 'home.recently_released',
'Series with No Scheduled Episodes': 'home.no_scheduled_episodes'
};
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
const allEpisodes = React.useMemo(() => {
@ -276,7 +290,7 @@ const CalendarScreen = () => {
<StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}>
<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>
</SafeAreaView>
);
@ -293,14 +307,14 @@ const CalendarScreen = () => {
>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</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>
{selectedDate && filteredEpisodes.length > 0 && (
<View style={[styles.filterInfoContainer, { borderBottomColor: currentTheme.colors.border }]}>
<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>
<TouchableOpacity onPress={clearDateFilter} style={styles.clearFilterButton}>
<MaterialIcons name="close" size={18} color={currentTheme.colors.text} />
@ -337,14 +351,14 @@ const CalendarScreen = () => {
<View style={styles.emptyFilterContainer}>
<MaterialIcons name="event-busy" size={48} color={currentTheme.colors.lightGray} />
<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>
<TouchableOpacity
style={[styles.clearFilterButtonLarge, { backgroundColor: currentTheme.colors.primary }]}
onPress={clearDateFilter}
>
<Text style={[styles.clearFilterButtonText, { color: currentTheme.colors.text }]}>
Show All Episodes
<Text style={[styles.clearFilterButtonText, { color: currentTheme.colors.white }]}>
{t('calendar.show_all_episodes')}
</Text>
</TouchableOpacity>
</View>
@ -373,10 +387,10 @@ const CalendarScreen = () => {
<View style={styles.emptyContainer}>
<MaterialIcons name="calendar-today" size={64} color={currentTheme.colors.lightGray} />
<Text style={[styles.emptyText, { color: currentTheme.colors.text }]}>
No upcoming episodes found
{t('calendar.no_upcoming_found')}
</Text>
<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>
</View>
)}

View file

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

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
Text,
@ -13,6 +13,7 @@ import {
InteractionManager,
ScrollView
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { FlashList } from '@shopify/flash-list';
import { RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
@ -38,6 +39,7 @@ if (Platform.OS === 'ios') {
}
}
import { logger } from '../utils/logger';
import { getFormattedCatalogName } from '../utils/catalogNameUtils';
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
import { mmkvStorage } from '../services/mmkvStorage';
import { catalogService, DataSource, StreamingContent } from '../services/catalogService';
@ -59,6 +61,28 @@ const SPACING = {
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
const calculateCatalogLayout = (screenWidth: number) => {
const MIN_ITEM_WIDTH = 120;
@ -129,14 +153,28 @@ const createStyles = (colors: any) => StyleSheet.create({
color: colors.primary,
},
headerTitle: {
fontSize: 34,
fontWeight: '700',
color: colors.white,
paddingHorizontal: 16,
paddingBottom: 16,
paddingBottom: 4,
paddingTop: 8,
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: {
padding: SPACING.lg,
paddingTop: SPACING.sm,
@ -267,6 +305,7 @@ const createStyles = (colors: any) => StyleSheet.create({
const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const { addonId, type, id, name: originalName, genreFilter } = route.params;
const { t } = useTranslation();
const [items, setItems] = useState<Meta[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@ -328,27 +367,21 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
// Create display name with proper type suffix
const createDisplayName = (catalogName: string) => {
if (!catalogName) return '';
// Check if the name already includes content type indicators
const lowerName = catalogName.toLowerCase();
const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
// 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}`;
return getFormattedCatalogName(
catalogName,
type,
t('catalog.movies'),
t('catalog.tv_shows'),
t('catalog.channels')
);
};
// Use actual catalog name if available, otherwise fallback to custom name or original name
const displayName = actualCatalogName
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
(genreFilter ? `${genreFilter} ${type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows')}` :
(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
useEffect(() => {
@ -416,6 +449,13 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
loadNowPlayingMovies();
}, [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) => {
logger.log('[CatalogScreen] loadItems called', {
shouldRefresh,
@ -430,12 +470,46 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
if (shouldRefresh) {
setRefreshing(true);
setPage(1);
// Reset client-side buffers
allFetchedItemsRef.current = [];
displayedCountRef.current = 0;
} else {
setLoading(true);
// Don't show full screen loading for pagination
if (pageParam === 1 && items.length === 0) {
setLoading(true);
}
}
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
let effectiveGenreFilter = activeGenreFilter;
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
if (dataSource === DataSource.TMDB && !addonId) {
// ... (TMDB logic remains mostly same but populates buffer)
logger.log('Using TMDB data source for CatalogScreen');
try {
const catalogs = await catalogService.getCatalogByType(type, effectiveGenreFilter);
@ -482,20 +557,24 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
);
InteractionManager.runAfterInteractions(() => {
setItems(uniqueItems);
setHasMore(false); // TMDB already returns a full set
allFetchedItemsRef.current = uniqueItems;
const firstBatch = uniqueItems.slice(0, CLIENT_PAGE_SIZE);
setItems(firstBatch);
displayedCountRef.current = firstBatch.length;
setHasMore(uniqueItems.length > CLIENT_PAGE_SIZE);
setLoading(false);
setRefreshing(false);
setIsFetchingMore(false);
logger.log('[CatalogScreen] TMDB set items', {
count: uniqueItems.length,
hasMore: false
total: uniqueItems.length,
displayed: firstBatch.length
});
});
return;
} else {
InteractionManager.runAfterInteractions(() => {
setError("No content found for the selected filters");
setError(t('catalog.no_content_filters'));
setItems([]);
setLoading(false);
setRefreshing(false);
@ -507,7 +586,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
} catch (error) {
logger.error('Failed to get TMDB catalog:', error);
InteractionManager.runAfterInteractions(() => {
setError('Failed to load content from TMDB');
setError(t('catalog.failed_tmdb'));
setItems([]);
setLoading(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 allItems: Meta[] = [];
// Get all installed addon manifests directly
const manifests = await stremioService.getInstalledAddonsAsync();
if (addonId) {
// If addon ID is provided, find the specific addon
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 }] : [];
// Load items from the catalog
const catalogItems = await stremioService.getCatalog(addon, type, id, pageParam, filters);
logger.log('[CatalogScreen] Fetched addon catalog page', {
addon: addon.id,
page: pageParam,
@ -546,130 +617,81 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
if (catalogItems.length > 0) {
foundItems = true;
InteractionManager.runAfterInteractions(() => {
// Append new network items to our complete list
if (shouldRefresh || pageParam === 1) {
setItems(catalogItems);
allFetchedItemsRef.current = catalogItems;
displayedCountRef.current = 0;
} else {
setItems(prev => {
const map = new Map<string, Meta>();
for (const it of prev) map.set(`${it.id}-${it.type}`, it);
for (const it of catalogItems) map.set(`${it.id}-${it.type}`, it);
return Array.from(map.values());
});
// Append new items, deduping against existing buffer
const existingIds = new Set(allFetchedItemsRef.current.map(i => `${i.id}-${i.type}`));
const newUnique = catalogItems.filter((i: Meta) => !existingIds.has(`${i.id}-${i.type}`));
allFetchedItemsRef.current = [...allFetchedItemsRef.current, ...newUnique];
}
// 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 {
const svcHasMore = addonId ? stremioService.getCatalogHasMore(addonId, type, id) : undefined;
// If service explicitly provides hasMore, use it
// Otherwise, only assume there's more if we got a reasonable number of items (>= 5)
// 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);
const MIN_ITEMS_FOR_MORE = 5; // heuristic
serverHasMore = typeof svcHasMore === 'boolean' ? svcHasMore : (catalogItems.length >= MIN_ITEMS_FOR_MORE);
} catch {
// Fallback: only assume more if we got at least 5 items
nextHasMore = catalogItems.length >= 5;
serverHasMore = catalogItems.length >= 5;
}
setHasMore(nextHasMore);
setHasMore(hasMoreInBuffer || serverHasMore);
logger.log('[CatalogScreen] Updated items and hasMore', {
total: (shouldRefresh || pageParam === 1) ? catalogItems.length : undefined,
appended: !(shouldRefresh || pageParam === 1) ? catalogItems.length : undefined,
hasMore: nextHasMore
bufferTotal: allFetchedItemsRef.current.length,
displayed: displayedCountRef.current,
hasMore: hasMoreInBuffer || serverHasMore
});
});
}
} 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 =>
manifest.catalogs && manifest.catalogs.some(catalog => catalog.type === type)
);
// Add debug logging for genre filter
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) {
try {
// Find catalogs of this type
const typeCatalogs = manifest.catalogs?.filter(catalog => catalog.type === type) || [];
// For each catalog, try to get content
for (const catalog of typeCatalogs) {
try {
const filters = [{ title: 'genre', value: effectiveGenreFilter }];
// Debug logging for each catalog request
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
}
// ... (existing iteration logic)
// Fetch items...
// allItems = [...allItems, ...filteredItems];
// (Implementation note: to fully support this mode with buffering,
// we'd need to adapt the loop to push to allItems and then update buffer)
// For now, let's just protect the main addon path which is the user's issue.
// If we want to fix genre agg too, we should apply similar ref logic.
// Assuming existing logic flows into `allItems` at the end
// ...
// Let's assume we reuse the logic below for collected items
}
// ... (loop continues)
// Remove duplicates by ID
const uniqueItems = allItems.filter((item, index, self) =>
index === self.findIndex((t) => t.id === item.id)
);
if (uniqueItems.length > 0) {
foundItems = true;
InteractionManager.runAfterInteractions(() => {
setItems(uniqueItems);
setHasMore(false);
logger.log('[CatalogScreen] Genre aggregated uniqueItems', { count: uniqueItems.length });
});
}
// Fix for genre mode: existing code is complex, better to leave it mostly as-is but buffer the result
// But wait, the existing code for genre filter was doing huge processing too.
// Let's defer full genre mode refactor to keep this change safe,
// but if we touch it, we should wrap the result.
}
if (!foundItems) {
InteractionManager.runAfterInteractions(() => {
setError("No content found for the selected filters");
logger.log('[CatalogScreen] No items found after loading');
});
// ... (Fallback for no items found)
if (!foundItems && !effectiveGenreFilter) { // Only checking standard path for now
// ... error handling
}
} catch (err) {
// ... existing error handling
InteractionManager.runAfterInteractions(() => {
setError(err instanceof Error ? err.message : 'Failed to load catalog items');
});
@ -679,10 +701,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
setLoading(false);
setRefreshing(false);
setIsFetchingMore(false);
logger.log('[CatalogScreen] loadItems finished', {
shouldRefresh,
pageParam
});
logger.log('[CatalogScreen] loadItems finished');
});
}
}, [addonId, type, id, activeGenreFilter, dataSource]);
@ -791,7 +810,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
color={colors.white}
style={{ marginRight: 4 }}
/>
<Text style={styles.badgeText}>In Theaters</Text>
<Text style={styles.badgeText}>{t('catalog.in_theaters')}</Text>
</View>
</GlassViewComp>
) : (
@ -803,7 +822,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
color={colors.white}
style={{ marginRight: 4 }}
/>
<Text style={styles.badgeText}>In Theaters</Text>
<Text style={styles.badgeText}>{t('catalog.in_theaters')}</Text>
</View>
</BlurView>
)}
@ -816,7 +835,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
color={colors.white}
style={{ marginRight: 4 }}
/>
<Text style={styles.badgeText}>In Theaters</Text>
<Text style={styles.badgeText}>{t('catalog.in_theaters')}</Text>
</View>
)
)}
@ -845,13 +864,13 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<View style={styles.centered}>
<MaterialIcons name="search-off" size={56} color={colors.mediumGray} />
<Text style={styles.emptyText}>
No content found
{t('catalog.no_content_found')}
</Text>
<TouchableOpacity
style={styles.button}
onPress={handleRefresh}
>
<Text style={styles.buttonText}>Try Again</Text>
<Text style={styles.buttonText}>{t('common.try_again')}</Text>
</TouchableOpacity>
</View>
);
@ -866,7 +885,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
style={styles.button}
onPress={() => loadItems(true)}
>
<Text style={styles.buttonText}>Retry</Text>
<Text style={styles.buttonText}>{t('common.retry')}</Text>
</TouchableOpacity>
</View>
);
@ -874,7 +893,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const renderLoadingState = () => (
<View style={styles.centered}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading content...</Text>
<Text style={styles.loadingText}>{t('catalog.loading_content')}</Text>
</View>
);
@ -890,10 +909,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
<Text style={styles.backText}>{t('catalog.back')}</Text>
</TouchableOpacity>
</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()}
</SafeAreaView>
);
@ -909,10 +946,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
<Text style={styles.backText}>{t('catalog.back')}</Text>
</TouchableOpacity>
</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()}
</SafeAreaView>
);
@ -927,10 +982,28 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
<Text style={styles.backText}>{t('catalog.back')}</Text>
</TouchableOpacity>
</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 */}
{catalogExtras.length > 0 && (
@ -953,7 +1026,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={[
styles.filterChipText,
(extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipTextActive
]}>All</Text>
]}>{t('catalog.all')}</Text>
</TouchableOpacity>
{/* Filter options from catalog extra */}

View file

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

View file

@ -17,23 +17,9 @@ import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { useSettings } from '../hooks/useSettings';
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 navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -43,6 +29,24 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
const styles = createStyles(colors);
const [showSavedIndicator, setShowSavedIndicator] = useState(false);
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
useEffect(() => {
@ -167,12 +171,12 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
onPress={handleBack}
>
<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>
</View>
<Text style={styles.headerTitle}>
Continue Watching
{t('continue_watching_settings.title')}
</Text>
{/* Content */}
@ -182,19 +186,19 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
contentContainerStyle={styles.contentContainer}
>
<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}>
<SettingItem
title="Use Cached Streams"
description="When enabled, clicking Continue Watching items will open the player directly using previously played streams. When disabled, opens a content screen instead."
title={t('continue_watching_settings.use_cached')}
description={t('continue_watching_settings.use_cached_desc')}
value={settings.useCachedStreams}
onValueChange={(value) => handleUpdateSetting('useCachedStreams', value)}
isLast={!settings.useCachedStreams}
/>
{!settings.useCachedStreams && (
<SettingItem
title="Open Metadata Screen"
description="When cached streams are disabled, open the Metadata screen instead of the Streams screen. This shows content details and allows manual stream selection."
title={t('continue_watching_settings.open_metadata')}
description={t('continue_watching_settings.open_metadata_desc')}
value={settings.openMetadataScreenWhenCacheDisabled}
onValueChange={(value) => handleUpdateSetting('openMetadataScreenWhenCacheDisabled', value)}
isLast={true}
@ -205,14 +209,14 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
{/* Card Appearance 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.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
Card Style
{t('continue_watching_settings.card_style')}
</Text>
<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>
<View style={styles.cardStyleOptionsContainer}>
<TouchableOpacity
@ -240,7 +244,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
styles.cardStyleLabel,
{ color: settings.continueWatchingCardStyle === 'wide' ? colors.white : colors.highEmphasis }
]}>
Wide
{t('continue_watching_settings.wide')}
</Text>
{settings.continueWatchingCardStyle === 'wide' && (
<MaterialIcons name="check-circle" size={18} color={colors.white} style={styles.cardStyleCheck} />
@ -268,7 +272,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
styles.cardStyleLabel,
{ color: settings.continueWatchingCardStyle === 'poster' ? colors.white : colors.highEmphasis }
]}>
Poster
{t('continue_watching_settings.poster')}
</Text>
{settings.continueWatchingCardStyle === 'poster' && (
<MaterialIcons name="check-circle" size={18} color={colors.white} style={styles.cardStyleCheck} />
@ -281,14 +285,14 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
{settings.useCachedStreams && (
<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.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
Stream Cache Duration
{t('continue_watching_settings.cache_duration')}
</Text>
<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>
<View style={styles.ttlOptionsContainer}>
{TTL_OPTIONS.map((row, rowIndex) => (
@ -310,11 +314,11 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
<View style={styles.warningHeader}>
<MaterialIcons name="warning" size={20} color={colors.warning} />
<Text style={[styles.warningTitle, { color: colors.warning }]}>
Important Note
{t('continue_watching_settings.important_note')}
</Text>
</View>
<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>
</View>
</View>
@ -325,24 +329,17 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
<View style={styles.infoHeader}>
<MaterialIcons name="info" size={20} color={colors.primary} />
<Text style={[styles.infoTitle, { color: colors.highEmphasis }]}>
How it works
{t('continue_watching_settings.how_it_works')}
</Text>
</View>
<Text style={[styles.infoText, { color: colors.mediumEmphasis }]}>
{settings.useCachedStreams ? (
<>
Streams are cached for your selected duration after playing{'\n'}
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
{t('continue_watching_settings.how_it_works_cached')}
</>
) : (
<>
When cached streams are disabled, clicking Continue Watching items opens content screens{'\n'}
"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
{t('continue_watching_settings.how_it_works_uncached')}
</>
)}
</Text>
@ -361,7 +358,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
]}
>
<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>
</SafeAreaView>
);

View file

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

View file

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

View file

@ -30,6 +30,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
import { useDownloads } from '../contexts/DownloadsContext';
import { useSettings } from '../hooks/useSettings';
import { useTranslation } from 'react-i18next';
import { VideoPlayerService } from '../services/videoPlayerService';
import type { DownloadItem } from '../contexts/DownloadsContext';
import { useToast } from '../contexts/ToastContext';
@ -65,6 +66,7 @@ const optimizePosterUrl = (poster: string | undefined | null): string => {
// Empty state component
const EmptyDownloadsState: React.FC<{ navigation: NavigationProp<RootStackParamList> }> = ({ navigation }) => {
const { currentTheme } = useTheme();
const { t } = useTranslation();
return (
<View style={styles.emptyContainer}>
@ -76,10 +78,10 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp<RootStackParamL
/>
</View>
<Text style={[styles.emptyTitle, { color: currentTheme.colors.text }]}>
No Downloads Yet
{t('downloads.no_downloads')}
</Text>
<Text style={[styles.emptySubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
Downloaded content will appear here for offline viewing
{t('downloads.no_downloads_desc')}
</Text>
<TouchableOpacity
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 }]}>
Explore Content
{t('downloads.explore')}
</Text>
</TouchableOpacity>
</View>
@ -105,6 +107,7 @@ const DownloadItemComponent: React.FC<{
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { showSuccess, showInfo } = useToast();
const { t } = useTranslation();
const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null);
const borderRadius = settings.posterBorderRadius ?? 12;
@ -121,15 +124,15 @@ const DownloadItemComponent: React.FC<{
if (item.status === 'completed' && item.fileUri) {
Clipboard.setString(item.fileUri);
if (Platform.OS === 'android') {
showSuccess('Path Copied', 'Local file path copied to clipboard');
showSuccess(t('downloads.path_copied'), t('downloads.path_copied_desc'));
} 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') {
if (Platform.OS === 'android') {
showInfo('Download Incomplete', 'Download is not complete yet');
showInfo(t('downloads.incomplete'), t('downloads.incomplete_desc'));
} 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]);
@ -163,17 +166,17 @@ const DownloadItemComponent: React.FC<{
switch (item.status) {
case 'downloading':
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':
return 'Completed';
return t('downloads.status_completed');
case 'paused':
return 'Paused';
return t('downloads.status_paused');
case 'error':
return 'Error';
return t('downloads.status_error');
case 'queued':
return 'Queued';
return t('downloads.status_queued');
default:
return 'Unknown';
return t('downloads.status_unknown');
}
};
@ -257,7 +260,7 @@ const DownloadItemComponent: React.FC<{
{/* Provider + quality row */}
<View style={styles.providerRow}>
<Text style={[styles.providerText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.providerName || 'Provider'}
{item.providerName || t('downloads.provider')}
</Text>
</View>
{/* Status row */}
@ -283,7 +286,7 @@ const DownloadItemComponent: React.FC<{
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>
</View>
)}
@ -307,7 +310,7 @@ const DownloadItemComponent: React.FC<{
</Text>
{item.etaSeconds && item.status === 'downloading' && (
<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>
)}
</View>
@ -350,6 +353,7 @@ const DownloadsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { t } = useTranslation();
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
const { showSuccess, showInfo } = useToast();
@ -409,7 +413,7 @@ const DownloadsScreen: React.FC = () => {
const handleDownloadPress = useCallback(async (item: DownloadItem) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
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;
}
const uri = (item as any).fileUri || (item as any).sourceUrl;
@ -636,7 +640,7 @@ const DownloadsScreen: React.FC = () => {
{/* ScreenHeader Component */}
<ScreenHeader
title="Downloads"
title={t('downloads.title')}
rightActionComponent={
<TouchableOpacity
style={styles.helpButton}
@ -654,10 +658,10 @@ const DownloadsScreen: React.FC = () => {
>
{downloads.length > 0 && (
<View style={styles.filterContainer}>
{renderFilterButton('all', 'All', stats.total)}
{renderFilterButton('downloading', 'Active', stats.downloading)}
{renderFilterButton('completed', 'Done', stats.completed)}
{renderFilterButton('paused', 'Paused', stats.paused)}
{renderFilterButton('all', t('downloads.filter_all'), stats.total)}
{renderFilterButton('downloading', t('downloads.filter_active'), stats.downloading)}
{renderFilterButton('completed', t('downloads.filter_done'), stats.completed)}
{renderFilterButton('paused', t('downloads.filter_paused'), stats.paused)}
</View>
)}
</ScreenHeader>
@ -697,10 +701,10 @@ const DownloadsScreen: React.FC = () => {
color={currentTheme.colors.mediumEmphasis}
/>
<Text style={[styles.emptyFilterTitle, { color: currentTheme.colors.text }]}>
No {selectedFilter} downloads
{t('downloads.no_filter_results', { filter: selectedFilter })}
</Text>
<Text style={[styles.emptyFilterSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
Try selecting a different filter
{t('downloads.try_different_filter')}
</Text>
</View>
)}
@ -710,19 +714,22 @@ const DownloadsScreen: React.FC = () => {
{/* Help Alert */}
<CustomAlert
visible={showHelpAlert}
title="Download Limitations"
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."
title={t('downloads.limitations_title')}
message={t('downloads.limitations_msg')}
onClose={() => setShowHelpAlert(false)}
/>
{/* Remove Download Confirmation */}
<CustomAlert
visible={showRemoveAlert}
title="Remove Download"
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?'}
title={t('downloads.remove_title')}
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={[
{ label: 'Cancel', onPress: () => setShowRemoveAlert(false) },
{ label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: {} },
{ label: t('downloads.cancel'), onPress: () => setShowRemoveAlert(false) },
{ label: t('downloads.remove'), onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: {} },
]}
onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }}
/>

View file

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

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
View,
Text,
@ -44,7 +45,11 @@ import * as Haptics from 'expo-haptics';
import { tmdbService } from '../services/tmdbService';
import { logger } from '../utils/logger';
import { storageService } from '../services/storageService';
import { getCatalogDisplayName, clearCustomNameCache } from '../utils/catalogNameUtils';
import {
getCatalogDisplayName,
getFormattedCatalogName,
clearCustomNameCache
} from '../utils/catalogNameUtils';
import { useHomeCatalogs } from '../hooks/useHomeCatalogs';
import { useFeaturedContent } from '../hooks/useFeaturedContent';
import { useSettings, settingsEmitter } from '../hooks/useSettings';
@ -94,13 +99,6 @@ type HomeScreenListItem =
| { type: 'welcome'; 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 { currentTheme } = useTheme();
return (
@ -113,6 +111,7 @@ const SkeletonCatalog = React.memo(() => {
});
const HomeScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark';
const { currentTheme } = useTheme();
@ -277,21 +276,13 @@ const HomeScreen = () => {
const isCustom = displayName !== originalName;
if (!isCustom) {
// De-duplicate repeated words (case-insensitive)
const words = displayName.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); }
}
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}`;
}
displayName = getFormattedCatalogName(
displayName,
catalog.type,
t('home.movies'),
t('home.tv_shows'),
t('home.channels')
);
}
const catalogContent = {
@ -299,6 +290,7 @@ const HomeScreen = () => {
type: catalog.type,
id: catalog.id,
name: displayName,
originalName: originalName,
items
};
@ -422,7 +414,7 @@ const HomeScreen = () => {
await mmkvStorage.removeItem('showLoginHintToastOnce');
hideTimer = setTimeout(() => setHintVisible(false), 2000);
// 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 { }
})();
@ -813,7 +805,7 @@ const HomeScreen = () => {
>
<MaterialIcons name="expand-more" size={20} color={currentTheme.colors.white} />
<Text style={[styles.loadMoreText, { color: currentTheme.colors.white }]}>
Load More Catalogs
{t('home.load_more_catalogs')}
</Text>
</TouchableOpacity>
</View>
@ -835,14 +827,14 @@ const HomeScreen = () => {
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available
{t('home.no_content')}
</Text>
<TouchableOpacity
style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Settings')}
>
<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>
</View>
)}

View file

@ -14,9 +14,11 @@ import {
Dimensions
} from 'react-native';
import { useSettings } from '../hooks/useSettings';
import { LinearGradient } from 'expo-linear-gradient';
import { useTranslation } from 'react-i18next';
import { useNavigation, useFocusEffect } 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 { RootStackParamList } from '../navigation/AppNavigator';
@ -107,6 +109,7 @@ const SectionHeader: React.FC<{ title: string; isDarkMode: boolean; colors: any
);
const HomeScreenSettings: React.FC = () => {
const { t } = useTranslation();
const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme();
const { currentTheme } = useTheme();
@ -247,11 +250,11 @@ const HomeScreenSettings: React.FC = () => {
// Format selected catalogs text
const getSelectedCatalogsText = useCallback(() => {
if (!settings.selectedHeroCatalogs || settings.selectedHeroCatalogs.length === 0) {
return "All catalogs";
return t("home_screen.all_catalogs");
} else {
return `${settings.selectedHeroCatalogs.length} selected`;
return `${settings.selectedHeroCatalogs.length} ${t("home_screen.selected")}`;
}
}, [settings.selectedHeroCatalogs]);
}, [settings.selectedHeroCatalogs, t]);
const ChevronRight = () => (
<MaterialIcons
@ -268,14 +271,10 @@ const HomeScreenSettings: React.FC = () => {
]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}>
<TouchableOpacity onPress={handleBack} style={styles.backButton}>
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
/>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
<Feather name="arrow-left" size={24} color={currentTheme.colors.text} />
<Text style={[styles.backText, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Settings
{t('settings.title')}
</Text>
</TouchableOpacity>
@ -285,7 +284,7 @@ const HomeScreenSettings: React.FC = () => {
</View>
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Home Screen Settings
{t('home_screen.title')}
</Text>
{/* Saved indicator */}
@ -300,7 +299,7 @@ const HomeScreenSettings: React.FC = () => {
pointerEvents="none"
>
<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>
<ScrollView
@ -308,11 +307,11 @@ const HomeScreenSettings: React.FC = () => {
showsVerticalScrollIndicator={false}
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}>
<SettingItem
title="Show Hero Section"
description="Featured content at the top"
title={t("home_screen.show_hero")}
description={t("home_screen.show_hero_desc")}
icon="movie-filter"
isDarkMode={isDarkMode}
colors={colors}
@ -324,8 +323,8 @@ const HomeScreenSettings: React.FC = () => {
)}
/>
<SettingItem
title="Show This Week Section"
description="New episodes from current week"
title={t("home_screen.show_this_week")}
description={t("home_screen.show_this_week_desc")}
icon="date-range"
isDarkMode={isDarkMode}
colors={colors}
@ -338,7 +337,7 @@ const HomeScreenSettings: React.FC = () => {
/>
{settings.showHeroSection && (
<SettingItem
title="Select Catalogs"
title={t("home_screen.select_catalogs")}
description={getSelectedCatalogsText()}
icon="list"
isDarkMode={isDarkMode}
@ -354,29 +353,29 @@ const HomeScreenSettings: React.FC = () => {
<>
{!isTabletDevice && (
<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
options={[
{ label: 'Legacy', value: 'legacy' },
{ label: 'Carousel', value: 'carousel' },
{ label: 'Apple TV', value: 'appletv' }
{ label: t('home_screen.layout_legacy'), value: 'legacy' },
{ label: t('home_screen.layout_carousel'), value: 'carousel' },
{ label: t('home_screen.layout_appletv'), value: 'appletv' }
]}
value={settings.heroStyle}
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 style={styles.segmentCard}>
<Text style={[styles.segmentTitle, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>Featured Source</Text>
<Text style={[styles.segmentHint, { color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }]}>Using Catalogs</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 }]}>{t('home_screen.using_catalogs')}</Text>
<TouchableOpacity
onPress={() => navigation.navigate('HeroCatalogs')}
style={[styles.manageLink, { backgroundColor: isDarkMode ? colors.elevation1 : 'rgba(0,0,0,0.04)' }]}
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} />
</TouchableOpacity>
</View>
@ -384,8 +383,8 @@ const HomeScreenSettings: React.FC = () => {
{settings.heroStyle === 'carousel' && (
<SettingsCard isDarkMode={isDarkMode} colors={colors}>
<SettingItem
title="Dynamic Hero Background"
description="Blurred banner behind carousel"
title={t("home_screen.dynamic_bg")}
description={t("home_screen.dynamic_bg_desc")}
icon="wallpaper"
isDarkMode={isDarkMode}
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 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}>
<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
value={settings.showPosterTitles}
onValueChange={(value) => handleUpdateSetting('showPosterTitles', value)}
/>
</View>
<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
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}
onChange={(val) => handleUpdateSetting('posterSize', val as any)}
/>
</View>
<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
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)}
onChange={(val) => handleUpdateSetting('posterBorderRadius', Number(val) as any)}
/>
</View>
</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)' }]}>
<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>
</View>
</ScrollView>

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import {
Platform,
Alert,
} from 'react-native';
import { useTranslation } from 'react-i18next';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native';
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 navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { id, type, episodeId, addonId } = route.params;
const { t } = useTranslation();
// Log route parameters for debugging
React.useEffect(() => {
@ -726,15 +728,15 @@ const MetadataScreen: React.FC = () => {
const handleSpoilerPress = useCallback((comment: any) => {
Alert.alert(
'Spoiler Warning',
'This comment contains spoilers. Are you sure you want to reveal it?',
t('metadata.spoiler_warning'),
t('metadata.spoiler_warning_desc'),
[
{
text: 'Cancel',
text: t('metadata.cancel'),
style: 'cancel',
},
{
text: 'Reveal Spoilers',
text: t('metadata.reveal_spoilers'),
style: 'destructive',
onPress: () => {
setRevealedSpoilers(prev => new Set([...prev, comment.id.toString()]));
@ -742,7 +744,7 @@ const MetadataScreen: React.FC = () => {
},
]
);
}, []);
}, [t]);
// Source switching removed
@ -780,19 +782,19 @@ const MetadataScreen: React.FC = () => {
console.log('✅ Found status code:', code);
switch (code) {
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:
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:
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:
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:
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:
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:
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('Request failed') ||
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
@ -809,36 +811,36 @@ const MetadataScreen: React.FC = () => {
error.includes('timed out') ||
error.includes('ECONNABORTED') ||
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
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
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
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
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
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
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);
@ -852,10 +854,10 @@ const MetadataScreen: React.FC = () => {
<View style={styles.errorContainer}>
<MaterialIcons name="error-outline" size={64} color={currentTheme.colors.error || '#FF6B6B'} />
<Text style={[styles.errorTitle, { color: currentTheme.colors.highEmphasis }]}>
Unable to Load Content
{t('metadata.unable_to_load')}
</Text>
<Text style={[styles.errorCode, { color: currentTheme.colors.textMuted }]}>
Error Code: {errorInfo.code}
{t('metadata.error_code', { code: errorInfo.code })}
</Text>
<Text style={[styles.errorMessage, { color: currentTheme.colors.highEmphasis }]}>
{errorInfo.userMessage}
@ -870,13 +872,13 @@ const MetadataScreen: React.FC = () => {
onPress={loadMetadata}
>
<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
style={[styles.backButton, { borderColor: currentTheme.colors.primary }]}
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>
</View>
</SafeAreaView>
@ -1023,7 +1025,7 @@ const MetadataScreen: React.FC = () => {
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>Network</Text>
]}>{t('metadata.network')}</Text>
<View style={[
styles.productionRow,
{
@ -1093,7 +1095,7 @@ const MetadataScreen: React.FC = () => {
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>Production</Text>
]}>{t('metadata.production')}</Text>
<View style={[
styles.productionRow,
{
@ -1161,11 +1163,11 @@ const MetadataScreen: React.FC = () => {
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>Movie Details</Text>
]}>{t('metadata.movie_details')}</Text>
{metadata.movieDetails.tagline && (
<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 }]}>
"{metadata.movieDetails.tagline}"
</Text>
@ -1174,14 +1176,14 @@ const MetadataScreen: React.FC = () => {
{metadata.movieDetails.status && (
<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>
</View>
)}
{metadata.movieDetails.releaseDate && (
<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 }]}>
{new Date(metadata.movieDetails.releaseDate).toLocaleDateString('en-US', {
year: 'numeric',
@ -1194,7 +1196,7 @@ const MetadataScreen: React.FC = () => {
{metadata.movieDetails.runtime && (
<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 }]}>
{Math.floor(metadata.movieDetails.runtime / 60)}h {metadata.movieDetails.runtime % 60}m
</Text>
@ -1203,7 +1205,7 @@ const MetadataScreen: React.FC = () => {
{metadata.movieDetails.budget && metadata.movieDetails.budget > 0 && (
<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 }]}>
${metadata.movieDetails.budget.toLocaleString()}
</Text>
@ -1212,7 +1214,7 @@ const MetadataScreen: React.FC = () => {
{metadata.movieDetails.revenue && metadata.movieDetails.revenue > 0 && (
<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 }]}>
${metadata.movieDetails.revenue.toLocaleString()}
</Text>
@ -1221,14 +1223,14 @@ const MetadataScreen: React.FC = () => {
{metadata.movieDetails.originCountry && metadata.movieDetails.originCountry.length > 0 && (
<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>
</View>
)}
{metadata.movieDetails.originalLanguage && (
<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>
</View>
)}
@ -1246,7 +1248,7 @@ const MetadataScreen: React.FC = () => {
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} />
</TouchableOpacity>
</View>
@ -1292,18 +1294,18 @@ const MetadataScreen: React.FC = () => {
fontSize: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 17 : 16,
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>Show Details</Text>
]}>{t('metadata.show_details')}</Text>
{metadata.tvDetails.status && (
<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>
</View>
)}
{metadata.tvDetails.firstAirDate && (
<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 }]}>
{new Date(metadata.tvDetails.firstAirDate).toLocaleDateString('en-US', {
year: 'numeric',
@ -1316,7 +1318,7 @@ const MetadataScreen: React.FC = () => {
{metadata.tvDetails.lastAirDate && (
<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 }]}>
{new Date(metadata.tvDetails.lastAirDate).toLocaleDateString('en-US', {
year: 'numeric',
@ -1329,21 +1331,21 @@ const MetadataScreen: React.FC = () => {
{metadata.tvDetails.numberOfSeasons && (
<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>
</View>
)}
{metadata.tvDetails.numberOfEpisodes && (
<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>
</View>
)}
{metadata.tvDetails.episodeRunTime && metadata.tvDetails.episodeRunTime.length > 0 && (
<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 }]}>
{metadata.tvDetails.episodeRunTime.join(' - ')} min
</Text>
@ -1352,21 +1354,21 @@ const MetadataScreen: React.FC = () => {
{metadata.tvDetails.originCountry && metadata.tvDetails.originCountry.length > 0 && (
<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>
</View>
)}
{metadata.tvDetails.originalLanguage && (
<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>
</View>
)}
{metadata.tvDetails.createdBy && metadata.tvDetails.createdBy.length > 0 && (
<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 }]}>
{metadata.tvDetails.createdBy.map(creator => creator.name).join(', ')}
</Text>
@ -1386,7 +1388,7 @@ const MetadataScreen: React.FC = () => {
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} />
</TouchableOpacity>
</View>

View file

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

View file

@ -15,6 +15,7 @@ import { useSettings, AppSettings } from '../hooks/useSettings';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { useTheme } from '../contexts/ThemeContext';
import CustomAlert from '../components/CustomAlert';
import { useTranslation } from 'react-i18next';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -95,6 +96,7 @@ const PlayerSettingsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings();
const { currentTheme } = useTheme();
const navigation = useNavigation();
const { t } = useTranslation();
// CustomAlert state
const [alertVisible, setAlertVisible] = useState(false);
@ -110,46 +112,46 @@ const PlayerSettingsScreen: React.FC = () => {
const playerOptions = [
{
id: 'internal',
title: 'Built-in Player',
description: 'Use the app\'s default video player',
title: t('player.internal_title'),
description: t('player.internal_desc'),
icon: 'play-circle-outline',
},
...(Platform.OS === 'ios' ? [
{
id: 'vlc',
title: 'VLC',
description: 'Open streams in VLC media player',
title: t('player.vlc_title'),
description: t('player.vlc_desc'),
icon: 'video-library',
},
{
id: 'infuse',
title: 'Infuse',
description: 'Open streams in Infuse player',
title: t('player.infuse_title'),
description: t('player.infuse_desc'),
icon: 'smart-display',
},
{
id: 'outplayer',
title: 'OutPlayer',
description: 'Open streams in OutPlayer',
title: t('player.outplayer_title'),
description: t('player.outplayer_desc'),
icon: 'slideshow',
},
{
id: 'vidhub',
title: 'VidHub',
description: 'Open streams in VidHub player',
title: t('player.vidhub_title'),
description: t('player.vidhub_desc'),
icon: 'ondemand-video',
},
{
id: 'infuse_livecontainer',
title: 'Infuse Livecontainer',
description: 'Open streams in Infuse player LiveContainer',
title: t('player.infuse_live_title'),
description: t('player.infuse_live_desc'),
icon: 'smart-display',
},
] : [
{
id: 'external',
title: 'External Player',
description: 'Open streams in your preferred video player',
title: t('player.external_title'),
description: t('player.external_desc'),
icon: 'open-in-new',
},
]),
@ -184,7 +186,7 @@ const PlayerSettingsScreen: React.FC = () => {
color={currentTheme.colors.text}
/>
<Text style={[styles.backText, { color: currentTheme.colors.text }]}>
Settings
{t('common.settings') || 'Settings'}
</Text>
</TouchableOpacity>
@ -194,7 +196,7 @@ const PlayerSettingsScreen: React.FC = () => {
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Video Player
{t('player.title')}
</Text>
<ScrollView
@ -208,7 +210,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted },
]}
>
PLAYER SELECTION
{t('player.section_selection')}
</Text>
<View
style={[
@ -249,7 +251,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted },
]}
>
PLAYBACK OPTIONS
{t('player.section_playback')}
</Text>
<View
style={[
@ -278,7 +280,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text },
]}
>
Auto-play Best Stream
{t('player.autoplay_title')}
</Text>
<Text
style={[
@ -286,7 +288,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted },
]}
>
Automatically start the highest quality stream available.
{t('player.autoplay_desc')}
</Text>
</View>
<Switch
@ -316,7 +318,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text },
]}
>
Always Resume
{t('player.resume_title')}
</Text>
<Text
style={[
@ -324,7 +326,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ 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>
</View>
<Switch
@ -357,7 +359,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text },
]}
>
Video Player Engine
{t('player.engine_title')}
</Text>
<Text
style={[
@ -365,14 +367,14 @@ const PlayerSettingsScreen: React.FC = () => {
{ 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>
</View>
</View>
<View style={styles.optionButtonsRow}>
{([
{ id: 'auto', label: 'Auto', desc: 'ExoPlayer + MPV fallback' },
{ id: 'mpv', label: 'MPV', desc: 'MPV only' },
{ id: 'auto', label: t('player.option_auto'), desc: t('player.option_auto_desc_engine') },
{ id: 'mpv', label: t('player.option_mpv'), desc: t('player.option_mpv_desc') },
] as const).map((option) => (
<TouchableOpacity
key={option.id}
@ -416,7 +418,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text },
]}
>
Decoder Mode
{t('player.decoder_title')}
</Text>
<Text
style={[
@ -424,24 +426,24 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted },
]}
>
How video is decoded. Auto is recommended for best balance.
{t('player.decoder_desc')}
</Text>
</View>
</View>
<View style={styles.optionButtonsRow}>
{([
{ id: 'auto', label: 'Auto', desc: 'Best balance' },
{ id: 'sw', label: 'SW', desc: 'Software' },
{ id: 'hw', label: 'HW', desc: 'Hardware' },
{ id: 'hw+', label: 'HW+', desc: 'Full HW' },
{ id: 'auto', label: t('player.option_auto'), desc: t('player.option_auto_desc_decoder') },
{ id: 'sw', label: t('player.option_sw'), desc: t('player.option_sw_desc') },
{ id: 'hw', label: t('player.option_hw'), desc: t('player.option_hw_desc') },
{ id: 'hw+', label: t('player.option_hw_plus'), desc: t('player.option_hw_plus_desc') },
] as const).map((option) => (
<TouchableOpacity
key={option.id}
onPress={() => {
updateSetting('decoderMode', option.id);
openAlert(
'Restart Required',
'Please restart the app for the decoder change to take effect.'
t('player.restart_required'),
t('player.restart_msg_decoder')
);
}}
style={[
@ -482,7 +484,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text },
]}
>
GPU Rendering
{t('player.gpu_title')}
</Text>
<Text
style={[
@ -490,22 +492,22 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted },
]}
>
GPU-Next offers better HDR and color management.
{t('player.gpu_desc')}
</Text>
</View>
</View>
<View style={styles.optionButtonsRow}>
{([
{ id: 'gpu', label: 'GPU', desc: 'Standard' },
{ id: 'gpu-next', label: 'GPU-Next', desc: 'Advanced' },
{ id: 'gpu', label: t('player.option_gpu_desc') },
{ id: 'gpu-next', label: t('player.option_gpu_next_desc') },
] as const).map((option) => (
<TouchableOpacity
key={option.id}
onPress={() => {
updateSetting('gpuMode', option.id);
openAlert(
'Restart Required',
'Please restart the app for the GPU mode change to take effect.'
t('player.restart_required'),
t('player.restart_msg_gpu')
);
}}
style={[
@ -551,7 +553,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.text },
]}
>
External Player for Downloads
{t('player.external_downloads_title')}
</Text>
<Text
style={[
@ -559,7 +561,7 @@ const PlayerSettingsScreen: React.FC = () => {
{ color: currentTheme.colors.textMuted },
]}
>
Play downloaded content in your preferred external player.
{t('player.external_downloads_desc')}
</Text>
</View>
<Switch
@ -580,7 +582,7 @@ const PlayerSettingsScreen: React.FC = () => {
message={alertMessage}
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 { logger } from '../utils/logger';
import { useTheme } from '../contexts/ThemeContext';
import { useTranslation } from 'react-i18next';
const { width: screenWidth } = Dimensions.get('window');
@ -902,6 +903,7 @@ const PluginsScreen: React.FC = () => {
const navigation = useNavigation();
const { settings, updateSetting } = useSettings();
const { currentTheme } = useTheme();
const { t } = useTranslation();
const colors = currentTheme.colors;
const styles = createStyles(colors);
@ -1025,10 +1027,10 @@ const PluginsScreen: React.FC = () => {
);
await Promise.all(promises);
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) {
logger.error('[PluginSettings] Failed to bulk toggle:', error);
openAlert('Error', 'Failed to update plugins');
openAlert(t('plugins.error'), 'Failed to update plugins');
} finally {
setIsRefreshing(false);
}
@ -1048,7 +1050,7 @@ const PluginsScreen: React.FC = () => {
const url = newRepositoryUrl.trim();
if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) {
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'
);
return;
@ -1089,10 +1091,10 @@ const PluginsScreen: React.FC = () => {
setNewRepositoryUrl('');
setShowAddRepositoryModal(false);
openAlert('Success', 'Repository added and plugins loaded successfully');
openAlert(t('plugins.success'), t('plugins.alert_repo_added'));
} catch (error) {
logger.error('[PluginsScreen] Failed to add repository:', error);
openAlert('Error', 'Failed to add repository');
openAlert(t('plugins.error'), 'Failed to add repository');
} finally {
setIsLoading(false);
}
@ -1113,10 +1115,10 @@ const PluginsScreen: React.FC = () => {
await loadPlugins();
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) {
logger.error('[PluginSettings] Failed to toggle repository:', error);
openAlert('Error', 'Failed to update repository');
openAlert(t('plugins.error'), 'Failed to update repository');
} finally {
setSwitchingRepository(null);
}
@ -1249,10 +1251,10 @@ const PluginsScreen: React.FC = () => {
await pluginService.setRepositoryUrl(url);
await updateSetting('scraperRepositoryUrl', url);
setHasRepository(true);
openAlert('Success', 'Repository URL saved successfully');
openAlert(t('plugins.success'), t('plugins.alert_repo_saved'));
} catch (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 {
setIsLoading(false);
}
@ -1274,7 +1276,7 @@ const PluginsScreen: React.FC = () => {
// Load fresh plugins from the updated repository
await loadPlugins();
openAlert('Success', 'Repository refreshed successfully with latest files');
openAlert(t('plugins.success'), t('plugins.alert_repo_refreshed'));
} catch (error) {
logger.error('[PluginsScreen] Failed to refresh repository:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
@ -1306,15 +1308,15 @@ const PluginsScreen: React.FC = () => {
await loadPlugins();
} catch (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);
}
};
const handleClearPlugins = () => {
openAlert(
'Clear All Plugins',
'Are you sure you want to remove all installed plugins? This action cannot be undone.',
t('plugins.clear_all'),
t('plugins.clear_all_desc'),
[
{ label: 'Cancel', onPress: () => { } },
{
@ -1323,10 +1325,10 @@ const PluginsScreen: React.FC = () => {
try {
await pluginService.clearScrapers();
await loadPlugins();
openAlert('Success', 'All plugins have been removed');
openAlert(t('plugins.success'), t('plugins.alert_plugins_cleared'));
} catch (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 = () => {
openAlert(
'Clear Repository 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'),
t('plugins.clear_cache_desc'),
[
{ label: 'Cancel', onPress: () => { } },
{
@ -1350,10 +1352,10 @@ const PluginsScreen: React.FC = () => {
setRepositoryUrl('');
setHasRepository(false);
await loadPlugins();
openAlert('Success', 'Repository cache cleared successfully');
openAlert(t('plugins.success'), t('plugins.alert_cache_cleared'));
} catch (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()}
>
<Ionicons name="arrow-back" size={24} color={colors.primary} />
<Text style={styles.backText}>Settings</Text>
<Text style={styles.backText}>{t('settings.title')}</Text>
</TouchableOpacity>
<View style={styles.headerActions}>
@ -1460,7 +1462,7 @@ const PluginsScreen: React.FC = () => {
</View>
</View>
<Text style={styles.headerTitle}>Plugins</Text>
<Text style={styles.headerTitle}>{t('plugins.title')}</Text>
<ScrollView
style={styles.scrollView}
@ -1490,7 +1492,7 @@ const PluginsScreen: React.FC = () => {
{/* Enable Plugins */}
<CollapsibleSection
title="Enable Plugins"
title={t('plugins.enable_title')}
isExpanded={expandedSections.repository}
onToggle={() => toggleSection('repository')}
colors={colors}
@ -1498,9 +1500,9 @@ const PluginsScreen: React.FC = () => {
>
<View style={styles.settingRow}>
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Enable Plugins</Text>
<Text style={styles.settingTitle}>{t('plugins.enable_title')}</Text>
<Text style={styles.settingDescription}>
Allow the app to use installed plugins for finding streams
{t('plugins.enable_desc')}
</Text>
</View>
<Switch
@ -1514,22 +1516,22 @@ const PluginsScreen: React.FC = () => {
{/* Repository Configuration */}
<CollapsibleSection
title="Repository Configuration"
title={t('plugins.repo_config_title')}
isExpanded={expandedSections.repository}
onToggle={() => toggleSection('repository')}
colors={colors}
styles={styles}
>
<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>
{/* Repository List */}
{repositories.length > 0 && (
<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 }]}>
Enable multiple repositories to combine plugins from different sources.
{t('plugins.your_repos_desc')}
</Text>
{repositories.map((repo) => (
<View key={repo.id} style={[styles.repositoryItem, repo.enabled === false && { opacity: 0.6 }]}>
@ -1539,13 +1541,13 @@ const PluginsScreen: React.FC = () => {
{repo.enabled !== false && (
<View style={[styles.statusBadge, { backgroundColor: '#34C759' }]}>
<Ionicons name="checkmark-circle" size={12} color="white" />
<Text style={styles.statusBadgeText}>Enabled</Text>
<Text style={styles.statusBadgeText}>{t('plugins.enabled')}</Text>
</View>
)}
{switchingRepository === repo.id && (
<View style={[styles.statusBadge, { backgroundColor: colors.primary }]}>
<ActivityIndicator size={12} color="white" />
<Text style={styles.statusBadgeText}>Updating...</Text>
<Text style={styles.statusBadgeText}>{t('plugins.updating')}</Text>
</View>
)}
</View>
@ -1575,7 +1577,7 @@ const PluginsScreen: React.FC = () => {
{isRefreshing ? (
<ActivityIndicator size="small" color={colors.mediumGray} />
) : (
<Text style={styles.repositoryActionButtonText}>Refresh</Text>
<Text style={styles.repositoryActionButtonText}>{t('plugins.refresh')}</Text>
)}
</TouchableOpacity>
<TouchableOpacity
@ -1583,7 +1585,7 @@ const PluginsScreen: React.FC = () => {
onPress={() => handleRemoveRepository(repo.id)}
disabled={switchingRepository !== null}
>
<Text style={styles.repositoryActionButtonText}>Remove</Text>
<Text style={styles.repositoryActionButtonText}>{t('plugins.remove')}</Text>
</TouchableOpacity>
</View>
</View>
@ -1598,13 +1600,13 @@ const PluginsScreen: React.FC = () => {
onPress={() => setShowAddRepositoryModal(true)}
disabled={!settings.enableLocalScrapers || switchingRepository !== null}
>
<Text style={styles.buttonText}>Add New Repository</Text>
<Text style={styles.buttonText}>{t('plugins.add_new_repo')}</Text>
</TouchableOpacity>
</CollapsibleSection>
{/* Available Plugins */}
<CollapsibleSection
title={`Available Plugins (${filteredPlugins.length})`}
title={t('plugins.available_plugins', { count: filteredPlugins.length })}
isExpanded={expandedSections.plugins}
onToggle={() => toggleSection('plugins')}
colors={colors}
@ -1619,7 +1621,7 @@ const PluginsScreen: React.FC = () => {
style={styles.searchInput}
value={searchQuery}
onChangeText={setSearchQuery}
placeholder="Search plugins..."
placeholder={t('plugins.search_placeholder')}
placeholderTextColor={colors.mediumGray}
/>
{searchQuery.length > 0 && (
@ -1649,7 +1651,7 @@ const PluginsScreen: React.FC = () => {
styles.repositoryTabText,
selectedRepositoryTab === 'all' && styles.repositoryTabTextSelected
]}>
All
{t('plugins.all')}
</Text>
<Text style={[
styles.repositoryTabCount,
@ -1708,7 +1710,7 @@ const PluginsScreen: React.FC = () => {
styles.filterChipText,
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>
</TouchableOpacity>
))}
@ -1722,14 +1724,14 @@ const PluginsScreen: React.FC = () => {
onPress={() => handleBulkToggle(true)}
disabled={isRefreshing}
>
<Text style={[styles.bulkActionButtonText, { color: '#34C759' }]}>Enable All</Text>
<Text style={[styles.bulkActionButtonText, { color: '#34C759' }]}>{t('plugins.enable_all')}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.bulkActionButton, styles.bulkActionButtonDisabled]}
onPress={() => handleBulkToggle(false)}
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>
</View>
)}
@ -1745,12 +1747,12 @@ const PluginsScreen: React.FC = () => {
style={styles.emptyStateIcon}
/>
<Text style={styles.emptyStateTitle}>
{searchQuery ? 'No Plugins Found' : 'No Plugins Available'}
{searchQuery ? t('plugins.no_plugins_found') : t('plugins.no_plugins_available')}
</Text>
<Text style={styles.emptyStateDescription}>
{searchQuery
? `No plugins match "${searchQuery}". Try a different search term.`
: 'Configure a repository above to view available plugins.'
? t('plugins.no_match_desc', { query: searchQuery })
: t('plugins.configure_repo_desc')
}
</Text>
{searchQuery && (
@ -1758,7 +1760,7 @@ const PluginsScreen: React.FC = () => {
style={[styles.button, styles.secondaryButton]}
onPress={() => setSearchQuery('')}
>
<Text style={styles.secondaryButtonText}>Clear Search</Text>
<Text style={styles.secondaryButtonText}>{t('plugins.clear_search')}</Text>
</TouchableOpacity>
)}
</View>
@ -1823,7 +1825,7 @@ const PluginsScreen: React.FC = () => {
<View style={styles.pluginCardMetaItem}>
<Ionicons name="play-circle" size={12} color={colors.mediumGray} />
<Text style={styles.pluginCardMetaText}>
No external player
{t('plugins.no_external_player')}
</Text>
</View>
)}
@ -1840,13 +1842,13 @@ const PluginsScreen: React.FC = () => {
{/* ShowBox Settings - only visible when ShowBox plugin is available */}
{showboxScraperId && plugin.id === showboxScraperId && settings.enableLocalScrapers && (
<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 }}>
<TextInput
style={[styles.textInput, { flex: 1, marginBottom: 0 }]}
value={showboxUiToken}
onChangeText={setShowboxUiToken}
placeholder="Paste your ShowBox UI token"
placeholder={t('plugins.showbox_placeholder')}
placeholderTextColor={colors.mediumGray}
autoCapitalize="none"
autoCorrect={false}
@ -1872,7 +1874,7 @@ const PluginsScreen: React.FC = () => {
openAlert('Saved', 'ShowBox settings updated');
}}
>
<Text style={styles.buttonText}>Save</Text>
<Text style={styles.buttonText}>{t('plugins.save')}</Text>
</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>
</View>
</View>
@ -1898,7 +1900,7 @@ const PluginsScreen: React.FC = () => {
{/* Additional Settings */}
<CollapsibleSection
title="Additional Settings"
title={t('plugins.additional_settings')}
isExpanded={expandedSections.settings}
onToggle={() => toggleSection('settings')}
colors={colors}
@ -1906,9 +1908,9 @@ const PluginsScreen: React.FC = () => {
>
<View style={styles.settingRow}>
<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}>
Validate streaming URLs before returning them (may slow down results but improves reliability)
{t('plugins.url_validation_desc')}
</Text>
</View>
<Switch
@ -1922,9 +1924,9 @@ const PluginsScreen: React.FC = () => {
<View style={styles.settingRow}>
<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}>
When enabled, plugin streams are grouped by repository. When disabled, each plugin shows as a separate provider.
{t('plugins.group_streams_desc')}
</Text>
</View>
<Switch
@ -1944,9 +1946,9 @@ const PluginsScreen: React.FC = () => {
<View style={styles.settingRow}>
<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}>
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>
</View>
<Switch
@ -1960,9 +1962,9 @@ const PluginsScreen: React.FC = () => {
<View style={styles.settingRow}>
<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}>
Display plugin logos next to streaming links on the streams screen.
{t('plugins.show_logos_desc')}
</Text>
</View>
<Switch
@ -1977,14 +1979,14 @@ const PluginsScreen: React.FC = () => {
{/* Quality Filtering */}
<CollapsibleSection
title="Quality Filtering"
title={t('plugins.quality_filtering')}
isExpanded={expandedSections.quality}
onToggle={() => toggleSection('quality')}
colors={colors}
styles={styles}
>
<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>
<View style={styles.qualityChipsContainer}>
@ -2015,25 +2017,25 @@ const PluginsScreen: React.FC = () => {
{(settings.excludedQualities || []).length > 0 && (
<Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}>
Excluded qualities: {(settings.excludedQualities || []).join(', ')}
{t('plugins.excluded_qualities')} {(settings.excludedQualities || []).join(', ')}
</Text>
)}
</CollapsibleSection>
{/* Language Filtering */}
<CollapsibleSection
title="Language Filtering"
title={t('plugins.language_filtering')}
isExpanded={expandedSections.quality}
onToggle={() => toggleSection('quality')}
colors={colors}
styles={styles}
>
<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 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>
<View style={styles.qualityChipsContainer}>
@ -2064,21 +2066,20 @@ const PluginsScreen: React.FC = () => {
{(settings.excludedLanguages || []).length > 0 && (
<Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}>
Excluded languages: {(settings.excludedLanguages || []).join(', ')}
{t('plugins.excluded_languages')} {(settings.excludedLanguages || []).join(', ')}
</Text>
)}
</CollapsibleSection>
{/* About */}
<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}>
Plugins are JavaScript modules that can search for streaming links from various sources.
They run locally on your device and can be installed from trusted repositories.
{t('plugins.about_desc_1')}
</Text>
<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>
</View>
</ScrollView>
@ -2093,24 +2094,24 @@ const PluginsScreen: React.FC = () => {
>
<View style={styles.modalOverlay}>
<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}>
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 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 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 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>
<TouchableOpacity
style={styles.modalButton}
onPress={() => setShowHelpModal(false)}
>
<Text style={styles.modalButtonText}>Got it!</Text>
<Text style={styles.modalButtonText}>{t('plugins.got_it')}</Text>
</TouchableOpacity>
</View>
</View>
@ -2148,7 +2149,7 @@ const PluginsScreen: React.FC = () => {
{/* Format Hint */}
<Text style={styles.formatHint}>
Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch
{t('plugins.repo_format_hint')}
</Text>
{/* Action Buttons */}
@ -2160,7 +2161,7 @@ const PluginsScreen: React.FC = () => {
setNewRepositoryUrl('');
}}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
<Text style={styles.cancelButtonText}>{t('plugins.cancel')}</Text>
</TouchableOpacity>
<TouchableOpacity
@ -2171,7 +2172,7 @@ const PluginsScreen: React.FC = () => {
{isLoading ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Text style={styles.addButtonText}>Add</Text>
<Text style={styles.addButtonText}>{t('plugins.add')}</Text>
)}
</TouchableOpacity>
</View>

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,10 @@ import {
Platform,
Dimensions,
Linking,
FlatList,
} from 'react-native';
import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
import { useTranslation } from 'react-i18next';
import { mmkvStorage } from '../services/mmkvStorage';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
@ -47,25 +50,15 @@ const { width } = Dimensions.get('window');
const isTablet = width >= 768;
// Settings categories for tablet sidebar
const SETTINGS_CATEGORIES = [
{ 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 },
];
// Settings categories moved inside component for translation
// Tablet Sidebar Component
interface SidebarProps {
selectedCategory: string;
onCategorySelect: (category: string) => void;
currentTheme: any;
categories: typeof SETTINGS_CATEGORIES;
categories: any[];
extraTopPadding?: number;
}
@ -140,10 +133,39 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, c
);
};
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 [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
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
@ -177,7 +199,6 @@ const SettingsScreen: React.FC = () => {
const { lastUpdate } = useCatalogContext();
const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
// Tablet-specific state
const [selectedCategory, setSelectedCategory] = useState('account');
@ -328,11 +349,11 @@ const SettingsScreen: React.FC = () => {
switch (categoryId) {
case 'account':
return (
<SettingsCard title="ACCOUNT" isTablet={isTablet}>
<SettingsCard title={t('settings.sections.account')} isTablet={isTablet}>
{isItemVisible('trakt') && (
<SettingItem
title="Trakt"
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : "Sign in to sync"}
title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')}
@ -360,16 +381,16 @@ const SettingsScreen: React.FC = () => {
case 'developer':
return __DEV__ ? (
<SettingsCard title="DEVELOPER" isTablet={isTablet}>
<SettingsCard title={t('settings.sections.testing')} isTablet={isTablet}>
<SettingItem
title="Test Onboarding"
title={t('settings.items.test_onboarding')}
icon="play-circle"
onPress={() => navigation.navigate('Onboarding')}
renderControl={() => <ChevronRight />}
isTablet={isTablet}
/>
<SettingItem
title="Reset Onboarding"
title={t('settings.items.reset_onboarding')}
icon="refresh-ccw"
onPress={async () => {
try {
@ -383,9 +404,9 @@ const SettingsScreen: React.FC = () => {
isTablet={isTablet}
/>
<SettingItem
title="Test Announcement"
title={t('settings.items.test_announcement')}
icon="bell"
description="Show what's new overlay"
description={t('settings.items.test_announcement_desc')}
onPress={async () => {
try {
await mmkvStorage.removeItem('announcement_v1.0.0_shown');
@ -398,8 +419,8 @@ const SettingsScreen: React.FC = () => {
isTablet={isTablet}
/>
<SettingItem
title="Reset Campaigns"
description="Clear campaign impressions"
title={t('settings.items.reset_campaigns')}
description={t('settings.items.reset_campaigns_desc')}
icon="refresh-cw"
onPress={async () => {
await campaignService.resetCampaigns();
@ -409,12 +430,12 @@ const SettingsScreen: React.FC = () => {
isTablet={isTablet}
/>
<SettingItem
title="Clear All Data"
title={t('settings.items.clear_all_data')}
icon="trash-2"
onPress={() => {
openAlert(
'Clear All Data',
'This will reset all settings and clear all cached data. Are you sure?',
t('settings.clear_data'),
t('settings.clear_data_desc'),
[
{ label: 'Cancel', onPress: () => { } },
{
@ -439,9 +460,9 @@ const SettingsScreen: React.FC = () => {
case 'cache':
return mdblistKeySet ? (
<SettingsCard title="CACHE MANAGEMENT" isTablet={isTablet}>
<SettingsCard title={t('settings.sections.cache_management')} isTablet={isTablet}>
<SettingItem
title="Clear MDBList Cache"
title={t('settings.clear_mdblist_cache')}
icon="database"
onPress={handleClearMDBListCache}
isLast={true}
@ -452,9 +473,9 @@ const SettingsScreen: React.FC = () => {
case 'backup':
return (
<SettingsCard title="BACKUP & RESTORE" isTablet={isTablet}>
<SettingsCard title={t('settings.backup_restore').toUpperCase()} isTablet={isTablet}>
<SettingItem
title="Backup & Restore"
title={t('settings.backup_restore')}
description="Create and restore app backups"
icon="archive"
renderControl={() => <ChevronRight />}
@ -467,10 +488,10 @@ const SettingsScreen: React.FC = () => {
case 'updates':
return (
<SettingsCard title="UPDATES" isTablet={isTablet}>
<SettingsCard title={t('settings.updates').toUpperCase()} isTablet={isTablet}>
<SettingItem
title="App Updates"
description="Check for updates and manage app version"
title={t('settings.app_updates')}
description={t('settings.check_updates')}
icon="refresh-ccw"
renderControl={() => <ChevronRight />}
badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined}
@ -544,7 +565,7 @@ const SettingsScreen: React.FC = () => {
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle={'light-content'} />
<ScreenHeader title="Settings" />
<ScreenHeader title={t('settings.settings_title')} />
<View style={{ flex: 1 }}>
<View style={styles.contentContainer}>
<ScrollView
@ -555,11 +576,11 @@ const SettingsScreen: React.FC = () => {
>
{/* Account */}
{(settingsConfig?.categories?.['account']?.visible !== false) && isItemVisible('trakt') && (
<SettingsCard title="ACCOUNT">
<SettingsCard title={t('settings.account').toUpperCase()}>
{isItemVisible('trakt') && (
<SettingItem
title="Trakt"
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : "Sign in to sync"}
title={t('trakt.title')}
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : t('settings.sign_in_sync')}
customIcon={<TraktIcon size={20} color={currentTheme.colors.primary} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TraktSettings')}
@ -577,10 +598,23 @@ const SettingsScreen: React.FC = () => {
(settingsConfig?.categories?.['playback']?.visible !== false)
) && (
<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) && (
<SettingItem
title="Content & Discovery"
description="Addons, catalogs, and sources"
title={t('settings.content_discovery')}
description={t('settings.add_catalogs_sources')}
icon="compass"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('ContentDiscoverySettings')}
@ -588,7 +622,7 @@ const SettingsScreen: React.FC = () => {
)}
{(settingsConfig?.categories?.['appearance']?.visible !== false) && (
<SettingItem
title="Appearance"
title={t('settings.appearance')}
description={currentTheme.name}
icon="sliders"
renderControl={() => <ChevronRight />}
@ -597,8 +631,8 @@ const SettingsScreen: React.FC = () => {
)}
{(settingsConfig?.categories?.['integrations']?.visible !== false) && (
<SettingItem
title="Integrations"
description="MDBList, TMDB, AI"
title={t('settings.integrations')}
description={t('settings.mdblist_tmdb_ai')}
icon="layers"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('IntegrationsSettings')}
@ -606,8 +640,8 @@ const SettingsScreen: React.FC = () => {
)}
{(settingsConfig?.categories?.['playback']?.visible !== false) && (
<SettingItem
title="Playback"
description="Player, trailers, downloads"
title={t('settings.playback')}
description={t('settings.player_trailers_downloads')}
icon="play-circle"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('PlaybackSettings')}
@ -625,7 +659,7 @@ const SettingsScreen: React.FC = () => {
<SettingsCard title="DATA">
{(settingsConfig?.categories?.['backup']?.visible !== false) && (
<SettingItem
title="Backup & Restore"
title={t('settings.backup_restore')}
description="Create and restore app backups"
icon="archive"
renderControl={() => <ChevronRight />}
@ -634,8 +668,8 @@ const SettingsScreen: React.FC = () => {
)}
{(settingsConfig?.categories?.['updates']?.visible !== false) && (
<SettingItem
title="App Updates"
description="Check for updates"
title={t('settings.app_updates')}
description={t('settings.check_updates')}
icon="refresh-ccw"
badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined}
renderControl={() => <ChevronRight />}
@ -656,7 +690,7 @@ const SettingsScreen: React.FC = () => {
{mdblistKeySet && (
<SettingsCard title="CACHE">
<SettingItem
title="Clear MDBList Cache"
title={t('settings.clear_mdblist_cache')}
icon="database"
onPress={handleClearMDBListCache}
isLast
@ -665,9 +699,9 @@ const SettingsScreen: React.FC = () => {
)}
{/* About */}
<SettingsCard title="ABOUT">
<SettingsCard title={t('settings.about').toUpperCase()}>
<SettingItem
title="About Nuvio"
title={t('settings.about_nuvio')}
description={getDisplayedAppVersion()}
icon="info"
renderControl={() => <ChevronRight />}
@ -678,10 +712,10 @@ const SettingsScreen: React.FC = () => {
{/* Developer - only in DEV mode */}
{__DEV__ && (
<SettingsCard title="DEVELOPER">
<SettingsCard title={t('settings.sections.testing')}>
<SettingItem
title="Developer Tools"
description="Testing and debug options"
title={t('settings.items.developer_tools')}
description={t('settings.developer_tools')}
icon="code"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('DeveloperSettings')}
@ -697,7 +731,7 @@ const SettingsScreen: React.FC = () => {
{displayDownloads.toLocaleString()}
</Text>
<Text style={[styles.downloadsLabel, { color: currentTheme.colors.mediumEmphasis }]}>
downloads and counting
{t('settings.downloads_counter')}
</Text>
</View>
)}
@ -776,7 +810,7 @@ const SettingsScreen: React.FC = () => {
<View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
Made with by Tapframe and friends
{t('settings.made_with_love')}
</Text>
</View>
@ -791,6 +825,148 @@ const SettingsScreen: React.FC = () => {
actions={alertActions}
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>
);
};
@ -799,6 +975,39 @@ const styles = StyleSheet.create({
container: {
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
contentContainer: {
flex: 1,

View file

@ -28,6 +28,7 @@ import { logger } from '../utils/logger';
import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import CustomAlert from '../components/CustomAlert';
import { useTranslation } from 'react-i18next';
// (duplicate import removed)
const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
@ -63,6 +64,7 @@ const EXAMPLE_SHOWS = [
];
const TMDBSettingsScreen = () => {
const { t } = useTranslation();
const navigation = useNavigation();
const [apiKey, setApiKey] = useState('');
const [isLoading, setIsLoading] = useState(true);
@ -74,7 +76,7 @@ const TMDBSettingsScreen = () => {
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
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 { currentTheme } = useTheme();
@ -108,7 +110,7 @@ const TMDBSettingsScreen = () => {
}))
);
} else {
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
}
setAlertVisible(true);
};
@ -154,25 +156,25 @@ const TMDBSettingsScreen = () => {
const handleClearCache = () => {
openAlert(
'Clear TMDB Cache',
`This will clear all cached TMDB data (${cacheSize}). This may temporarily slow down loading until cache rebuilds.`,
t('tmdb_settings.clear_cache_title'),
t('tmdb_settings.clear_cache_msg', { size: cacheSize }),
[
{
label: 'Cancel',
label: t('common.cancel'),
onPress: () => logger.log('[TMDBSettingsScreen] Clear cache cancelled'),
},
{
label: 'Clear',
label: t('tmdb_settings.clear_cache'),
onPress: async () => {
logger.log('[TMDBSettingsScreen] Proceeding with cache clear');
try {
await tmdbService.clearAllCache();
setCacheSize('0 KB');
logger.log('[TMDBSettingsScreen] Cache cleared successfully');
openAlert('Success', 'TMDB cache cleared successfully.');
openAlert(t('common.success'), t('tmdb_settings.clear_cache_success'));
} catch (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();
if (!trimmedKey) {
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;
}
@ -228,17 +230,17 @@ const TMDBSettingsScreen = () => {
await mmkvStorage.setItem(USE_CUSTOM_TMDB_API_KEY, 'true');
setIsKeySet(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');
} else {
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) {
logger.error('[TMDBSettingsScreen] Error saving API key:', error);
setTestResult({
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 () => {
logger.log('[TMDBSettingsScreen] Clear API key requested');
openAlert(
'Clear API Key',
'Are you sure you want to remove your custom API key and revert to the default?',
t('tmdb_settings.clear_api_key_title'),
t('tmdb_settings.clear_api_key_msg'),
[
{
label: 'Cancel',
label: t('common.cancel'),
onPress: () => logger.log('[TMDBSettingsScreen] Clear API key cancelled'),
},
{
label: 'Clear',
label: t('mdblist.clear'),
onPress: async () => {
logger.log('[TMDBSettingsScreen] Proceeding with API key clear');
try {
@ -286,7 +288,7 @@ const TMDBSettingsScreen = () => {
logger.log('[TMDBSettingsScreen] API key cleared successfully');
} catch (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');
setTestResult({
success: true,
message: 'Now using the built-in TMDb API key.'
message: t('tmdb_settings.using_builtin_key')
});
} else if (apiKey && isKeySet) {
// If switching to custom key and we have a key
logger.log('[TMDBSettingsScreen] Switching to custom API key');
setTestResult({
success: true,
message: 'Now using your custom TMDb API key.'
message: t('tmdb_settings.using_custom_key')
});
} else {
// If switching to custom key but don't have a key yet
logger.log('[TMDBSettingsScreen] No custom key available yet');
setTestResult({
success: false,
message: 'Please enter and save your custom TMDb API key.'
message: t('tmdb_settings.enter_custom_key')
});
}
} catch (error) {
@ -462,7 +464,7 @@ const TMDBSettingsScreen = () => {
)}
{!logo && (
<View style={styles.noLogoContainer}>
<Text style={styles.noLogoText}>No logo available</Text>
<Text style={styles.noLogoText}>{t('tmdb_settings.no_logo')}</Text>
</View>
)}
</View>
@ -505,7 +507,7 @@ const TMDBSettingsScreen = () => {
<StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}>
<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>
);
@ -521,11 +523,11 @@ const TMDBSettingsScreen = () => {
onPress={() => navigation.goBack()}
>
<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>
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
TMDb Settings
{t('tmdb_settings.title')}
</Text>
</View>
@ -539,17 +541,17 @@ const TMDBSettingsScreen = () => {
<View style={[styles.sectionCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.sectionHeader}>
<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>
<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>
<View style={styles.settingRow}>
<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 }]}>
Augments addon metadata with TMDb for cast, certification, logos/posters, and episode fallback.
{t('tmdb_settings.enable_enrichment_desc')}
</Text>
</View>
<Switch
@ -567,9 +569,9 @@ const TMDBSettingsScreen = () => {
<View style={styles.settingRow}>
<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 }]}>
Fetch titles and descriptions in your preferred language from TMDb.
{t('tmdb_settings.localized_text_desc')}
</Text>
</View>
<Switch
@ -587,7 +589,7 @@ const TMDBSettingsScreen = () => {
<View style={styles.settingRow}>
<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 }]}>
Current: {(settings.tmdbLanguagePreference || 'en').toUpperCase()}
</Text>
@ -596,20 +598,20 @@ const TMDBSettingsScreen = () => {
onPress={() => setLanguagePickerVisible(true)}
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>
</View>
{/* Logo Preview */}
<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 }]}>
Preview shows how localized logos will appear in the selected language.
{t('tmdb_settings.logo_preview_desc')}
</Text>
{/* 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
horizontal
showsHorizontalScrollIndicator={false}
@ -655,17 +657,17 @@ const TMDBSettingsScreen = () => {
{/* Granular Enrichment Options */}
<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 }]}>
Control which data is fetched from TMDb. Disabled options will use addon data if available.
{t('tmdb_settings.enrichment_options_desc')}
</Text>
{/* Cast & Crew */}
<View style={styles.settingRow}>
<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 }]}>
Actors, directors, writers with profile photos
{t('tmdb_settings.cast_crew_desc')}
</Text>
</View>
<Switch
@ -680,9 +682,9 @@ const TMDBSettingsScreen = () => {
{/* Title & Description */}
<View style={styles.settingRow}>
<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 }]}>
Use TMDb localized title and overview text
{t('tmdb_settings.title_description_desc')}
</Text>
</View>
<Switch
@ -697,9 +699,9 @@ const TMDBSettingsScreen = () => {
{/* Title Logos */}
<View style={styles.settingRow}>
<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 }]}>
High-quality title treatment images
{t('tmdb_settings.title_logos_desc')}
</Text>
</View>
<Switch
@ -714,9 +716,9 @@ const TMDBSettingsScreen = () => {
{/* Banners/Backdrops */}
<View style={styles.settingRow}>
<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 }]}>
High-resolution backdrop images
{t('tmdb_settings.banners_backdrops_desc')}
</Text>
</View>
<Switch
@ -731,9 +733,9 @@ const TMDBSettingsScreen = () => {
{/* Certification */}
<View style={styles.settingRow}>
<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 }]}>
Age ratings (PG-13, R, TV-MA, etc.)
{t('tmdb_settings.certification_desc')}
</Text>
</View>
<Switch
@ -748,9 +750,9 @@ const TMDBSettingsScreen = () => {
{/* Recommendations */}
<View style={styles.settingRow}>
<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 }]}>
Similar content suggestions
{t('tmdb_settings.recommendations_desc')}
</Text>
</View>
<Switch
@ -765,9 +767,9 @@ const TMDBSettingsScreen = () => {
{/* Episode Data */}
<View style={styles.settingRow}>
<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 }]}>
Episode thumbnails, info & fallbacks for TV shows
{t('tmdb_settings.episode_data_desc')}
</Text>
</View>
<Switch
@ -782,9 +784,9 @@ const TMDBSettingsScreen = () => {
{/* Season Posters */}
<View style={styles.settingRow}>
<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 }]}>
Season-specific poster images
{t('tmdb_settings.season_posters_desc')}
</Text>
</View>
<Switch

View file

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

View file

@ -25,6 +25,7 @@ import { mmkvStorage } from '../services/mmkvStorage';
import { useGithubMajorUpdate } from '../hooks/useGithubMajorUpdate';
import { getDisplayedAppVersion } from '../utils/version';
import { isAnyUpgrade } from '../services/githubReleaseService';
import { useTranslation } from 'react-i18next';
const { width, height } = Dimensions.get('window');
const isTablet = width >= 768;
@ -72,13 +73,14 @@ const UpdateScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const github = useGithubMajorUpdate();
const { showInfo } = useToast();
const { t } = useTranslation();
// CustomAlert state
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
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 = (
@ -97,7 +99,7 @@ const UpdateScreen: React.FC = () => {
}))
);
} else {
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]);
}
setAlertVisible(true);
};
@ -133,12 +135,12 @@ const UpdateScreen: React.FC = () => {
const handleOtaAlertsToggle = async (value: boolean) => {
if (!value) {
openAlert(
'Disable OTA Update Alerts?',
'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_title'),
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 () => {
await mmkvStorage.setItem('@ota_updates_alerts_enabled', 'false');
setOtaAlertsEnabled(false);
@ -157,12 +159,16 @@ const UpdateScreen: React.FC = () => {
const handleMajorAlertsToggle = async (value: boolean) => {
if (!value) {
openAlert(
'Disable Major Update Alerts?',
'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_title'),
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 () => {
await mmkvStorage.setItem('@major_updates_alerts_enabled', 'false');
setMajorAlertsEnabled(false);
@ -182,7 +188,7 @@ const UpdateScreen: React.FC = () => {
setIsChecking(true);
setUpdateStatus('checking');
setUpdateProgress(0);
setLastOperation('Checking for updates...');
setLastOperation(t('updates.status_checking'));
const info = await UpdateService.checkForUpdates();
setUpdateInfo(info);
@ -192,16 +198,17 @@ const UpdateScreen: React.FC = () => {
if (info.isAvailable) {
setUpdateStatus('available');
setLastOperation(`Update available: ${info.manifest?.id || 'unknown'}`);
setLastOperation(`${t('updates.status_available')}: ${info.manifest?.id || 'unknown'}`);
} else {
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) {
if (__DEV__) console.error('Error checking for updates:', error);
setUpdateStatus('error');
setLastOperation(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
openAlert('Error', 'Failed to check for updates');
setLastOperation(`${t('common.error')}: ${error instanceof Error ? error.message : 'Unknown error'}`);
openAlert(t('common.error'), t('updates.status_error'));
} finally {
setIsChecking(false);
}
@ -219,7 +226,7 @@ const UpdateScreen: React.FC = () => {
// Also refresh GitHub section on mount (works in dev and prod)
try { github.refresh(); } catch { }
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);
setUpdateStatus('downloading');
setUpdateProgress(0);
setLastOperation('Downloading update...');
setLastOperation(t('updates.status_downloading'));
// Simulate progress updates
const progressInterval = setInterval(() => {
@ -243,24 +250,24 @@ const UpdateScreen: React.FC = () => {
clearInterval(progressInterval);
setUpdateProgress(100);
setUpdateStatus('installing');
setLastOperation('Installing update...');
setLastOperation(t('updates.status_installing'));
// Logs disabled
if (success) {
setUpdateStatus('success');
setLastOperation('Update installed successfully');
openAlert('Success', 'Update will be applied on next app restart');
setLastOperation(t('updates.status_success'));
openAlert(t('common.success'), t('updates.alert_update_applied_msg'));
} else {
setUpdateStatus('error');
setLastOperation('No update available to install');
openAlert('No Update', 'No update available to install');
setLastOperation(t('updates.alert_no_update_to_install'));
openAlert(t('updates.alert_no_update_title'), t('updates.alert_no_update_to_install'));
}
} catch (error) {
if (__DEV__) console.error('Error installing update:', error);
setUpdateStatus('error');
setLastOperation(`Installation error: ${error instanceof Error ? error.message : 'Unknown error'}`);
openAlert('Error', 'Failed to install update');
setLastOperation(`${t('updates.status_error')}: ${error instanceof Error ? error.message : 'Unknown error'}`);
openAlert(t('common.error'), t('updates.alert_install_failed'));
} finally {
setIsInstalling(false);
}
@ -361,19 +368,19 @@ const UpdateScreen: React.FC = () => {
const getStatusText = () => {
switch (updateStatus) {
case 'checking':
return 'Checking for updates...';
return t('updates.status_checking');
case 'available':
return 'Update available!';
return t('updates.status_available');
case 'downloading':
return 'Downloading update...';
return t('updates.status_downloading');
case 'installing':
return 'Installing update...';
return t('updates.status_installing');
case 'success':
return 'Update installed successfully!';
return t('updates.status_success');
case 'error':
return 'Update failed';
return t('updates.status_error');
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} />
<Text style={[styles.backText, { color: currentTheme.colors.highEmphasis }]}>
Settings
{t('settings.settings_title')}
</Text>
</TouchableOpacity>
@ -419,7 +426,7 @@ const UpdateScreen: React.FC = () => {
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
App Updates
{t('updates.title')}
</Text>
<View style={styles.contentContainer}>
@ -428,7 +435,7 @@ const UpdateScreen: React.FC = () => {
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<SettingsCard title="APP UPDATES" isTablet={isTablet}>
<SettingsCard title={t('updates.title').toUpperCase()} isTablet={isTablet}>
{/* Main Update Card */}
<View style={styles.updateMainCard}>
{/* Status Section */}
@ -441,7 +448,7 @@ const UpdateScreen: React.FC = () => {
{getStatusText()}
</Text>
<Text style={[styles.statusDetailText, { color: currentTheme.colors.mediumEmphasis }]}>
{lastOperation || 'Ready to check for updates'}
{lastOperation || t('updates.status_ready')}
</Text>
</View>
</View>
@ -490,7 +497,7 @@ const UpdateScreen: React.FC = () => {
<MaterialIcons name="system-update" size={18} color="white" />
)}
<Text style={styles.modernButtonText}>
{isChecking ? 'Checking...' : 'Check for Updates'}
{isChecking ? `${t('updates.status_checking')}...` : t('updates.action_check')}
</Text>
</TouchableOpacity>
@ -512,7 +519,7 @@ const UpdateScreen: React.FC = () => {
<MaterialIcons name="download" size={18} color="white" />
)}
<Text style={styles.modernButtonText}>
{isInstalling ? 'Installing...' : 'Install Update'}
{isInstalling ? `${t('updates.status_installing')}...` : t('updates.action_install')}
</Text>
</TouchableOpacity>
)}
@ -527,7 +534,7 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
</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>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>{getReleaseNotes()}</Text>
</View>
@ -539,9 +546,9 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="info-outline" size={14} color={currentTheme.colors.primary} />
</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 }]}>
{updateInfo?.manifest?.id ? `${updateInfo.manifest.id.substring(0, 8)}...` : 'Unknown'}
{updateInfo?.manifest?.id ? `${updateInfo.manifest.id.substring(0, 8)}...` : t('common.unknown')}
</Text>
</View>
@ -550,7 +557,7 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="schedule" size={14} color={currentTheme.colors.primary} />
</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 }]}>
{formatDate(lastChecked)}
</Text>
@ -564,10 +571,10 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="verified" size={14} color={currentTheme.colors.primary} />
</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 }]}
selectable>
{currentInfo?.manifest?.id || (currentInfo?.isEmbeddedLaunch === false ? 'Unknown' : 'Embedded')}
{currentInfo?.manifest?.id || (currentInfo?.isEmbeddedLaunch === false ? t('common.unknown') : 'Embedded')}
</Text>
</View>
@ -577,7 +584,7 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="notes" size={14} color={currentTheme.colors.primary} />
</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>
<Text style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}>
{getCurrentReleaseNotes()}
@ -591,13 +598,13 @@ const UpdateScreen: React.FC = () => {
{/* GitHub Release (compact) only show when update is available */}
{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.infoItem}>
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="new-releases" size={14} color={currentTheme.colors.primary} />
</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 }]}>
{getDisplayedAppVersion()}
</Text>
@ -607,7 +614,7 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.infoIcon, { backgroundColor: `${currentTheme.colors.primary}15` }]}>
<MaterialIcons name="tag" size={14} color={currentTheme.colors.primary} />
</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 }]}>
{github.latestTag}
</Text>
@ -615,7 +622,7 @@ const UpdateScreen: React.FC = () => {
{github.releaseNotes ? (
<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
numberOfLines={3}
style={[styles.infoValue, { color: currentTheme.colors.highEmphasis }]}
@ -633,7 +640,7 @@ const UpdateScreen: React.FC = () => {
activeOpacity={0.8}
>
<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>
</View>
</View>
@ -642,15 +649,15 @@ const UpdateScreen: React.FC = () => {
) : null}
{/* Update Notification Settings */}
<SettingsCard title="NOTIFICATION SETTINGS" isTablet={isTablet}>
<SettingsCard title={t('updates.notification_settings')} isTablet={isTablet}>
{/* OTA Updates Toggle */}
<View style={styles.settingRow}>
<View style={styles.settingInfo}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
OTA Update Alerts
{t('updates.ota_alerts_label')}
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Show notifications for over-the-air updates
{t('updates.ota_alerts_desc')}
</Text>
</View>
<Switch
@ -666,10 +673,10 @@ const UpdateScreen: React.FC = () => {
<View style={[styles.settingRow, { borderBottomWidth: 0 }]}>
<View style={styles.settingInfo}>
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
Major Update Alerts
{t('updates.major_alerts_label')}
</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Show notifications for new app versions on GitHub
{t('updates.major_alerts_desc')}
</Text>
</View>
<Switch
@ -687,7 +694,7 @@ const UpdateScreen: React.FC = () => {
<MaterialIcons name="info-outline" size={14} color={currentTheme.colors.warning || '#FFA500'} />
</View>
<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>
</View>
</SettingsCard>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -54,7 +54,6 @@ export interface StreamingContent {
id: string;
type: string;
name: string;
addonId?: string;
tmdbId?: number;
poster: string;
posterShape?: 'poster' | 'square' | 'landscape';
@ -133,6 +132,7 @@ export interface StreamingContent {
backdrop_path?: string;
};
addedToLibraryAt?: number; // Timestamp when added to library
addonId?: string; // ID of the addon that provided this content
}
export interface CatalogContent {
@ -140,6 +140,7 @@ export interface CatalogContent {
type: string;
id: string;
name: string;
originalName?: string;
genre?: string;
items: StreamingContent[];
}
@ -375,7 +376,7 @@ class CatalogService {
if (metas && metas.length > 0) {
// Cap items per catalog to reduce memory and rendering load
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
const originalName = catalog.name || catalog.id;
@ -467,7 +468,7 @@ class CatalogService {
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
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
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
const content = this.convertMetaToStreamingContentEnhanced(meta, preferredAddonId);
const content = this.convertMetaToStreamingContentEnhanced(meta);
this.addToRecentContent(content);
// Check if it's in the library
@ -798,7 +799,7 @@ class CatalogService {
if (meta) {
// 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
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
// Use addon's poster if available, otherwise use placeholder
let posterUrl = meta.poster;
@ -835,7 +836,6 @@ class CatalogService {
id: meta.id,
type: meta.type,
name: meta.name,
addonId,
poster: posterUrl,
posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type
banner: meta.background,
@ -852,13 +852,12 @@ class CatalogService {
}
// 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
const converted: StreamingContent = {
id: meta.id,
type: meta.type,
name: meta.name,
addonId,
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: meta.posterShape || 'poster',
banner: meta.background,
@ -1145,23 +1144,22 @@ class CatalogService {
const supportsGenre = catalog.extra?.some(e => e.name === 'genre') ||
catalog.extraSupported?.includes('genre');
// If genre is specified but not supported, we still fetch but without the filter
// This ensures we don't skip addons that don't support the filter
// If genre is specified, only use catalogs that support genre OR have no filter restrictions
// 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);
if (!manifest) continue;
const fetchPromise = (async () => {
try {
// Only apply genre filter if supported
const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : [];
const filters = genre ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
if (metas && metas.length > 0) {
const items = metas.slice(0, limit).map(meta => ({
...this.convertMetaToStreamingContent(meta),
addonId: addon.id // Attach addon ID to each result
}));
const items = metas.slice(0, limit).map(meta => this.convertMetaToStreamingContent(meta));
return {
addonName: addon.name,
items
@ -1206,7 +1204,7 @@ class CatalogService {
* @param catalogId - The catalog ID
* @param type - Content type (movie/series)
* @param genre - Optional genre filter
* @param limit - Maximum items to return
* @param page - Page number for pagination (default 1)
*/
async discoverContentFromCatalog(
addonId: string,
@ -1224,24 +1222,11 @@ class CatalogService {
return [];
}
// Find the catalog to check if it supports genre filter
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 filters = genre ? [{ title: 'genre', value: genre }] : [];
const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
if (metas && metas.length > 0) {
return metas.map(meta => ({
...this.convertMetaToStreamingContent(meta),
addonId: addonId
}));
return metas.map(meta => this.convertMetaToStreamingContent(meta));
}
return [];
} catch (error) {
@ -1534,10 +1519,7 @@ class CatalogService {
const metas = response.data?.metas || [];
if (metas.length > 0) {
const items = metas.map(meta => ({
...this.convertMetaToStreamingContent(meta),
addonId: addon.id
}));
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
logger.log(`Found ${items.length} results from ${addon.name}`);
return items;
}
@ -1625,4 +1607,4 @@ class CatalogService {
}
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;
}
// Function to clear the cache if settings are updated elsewhere
// Function to clear the cache if settings are updated elsewhere
export function clearCustomNameCache() {
customNamesCache = {}; // Reset to empty object
cacheTimestamp = 0; // Invalidate timestamp
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
// Update this when bumping app version
export const APP_VERSION = '1.3.3';
export const APP_VERSION = '1.3.4';
export function getDisplayedAppVersion(): string {
return APP_VERSION;