diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 7addb62f..c0d97c2a 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -87,6 +87,7 @@ export interface AppSettings { openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour) enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile + useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content } export const DEFAULT_SETTINGS: AppSettings = { @@ -122,6 +123,7 @@ export const DEFAULT_SETTINGS: AppSettings = { alwaysResume: true, // Downloads enableDownloads: false, + useExternalPlayerForDownloads: false, // Theme defaults themeId: 'default', customThemes: [], @@ -162,12 +164,12 @@ export const useSettings = () => { useEffect(() => { loadSettings(); - + // Subscribe to settings changes const unsubscribe = settingsEmitter.addListener(() => { loadSettings(); }); - + return unsubscribe; }, []); @@ -183,13 +185,13 @@ export const useSettings = () => { const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; - + // Use synchronous MMKV reads for better performance const [scopedJson, legacyJson] = await Promise.all([ mmkvStorage.getItem(scopedKey), mmkvStorage.getItem(SETTINGS_STORAGE_KEY), ]); - + const parsedScoped = scopedJson ? JSON.parse(scopedJson) : null; const parsedLegacy = legacyJson ? JSON.parse(legacyJson) : null; @@ -202,16 +204,16 @@ export const useSettings = () => { if (scoped) { try { merged = JSON.parse(scoped); - } catch {} + } catch { } } } const finalSettings = merged ? { ...DEFAULT_SETTINGS, ...merged } : DEFAULT_SETTINGS; - + // Update cache cachedSettings = finalSettings; settingsCacheTimestamp = now; - + setSettings(finalSettings); } catch (error) { if (__DEV__) console.error('Failed to load settings:', error); @@ -231,23 +233,23 @@ export const useSettings = () => { ) => { const newSettings = { ...settings, [key]: value }; try { - const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; - const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; - // Write to both scoped key (multi-user aware) and legacy key for backward compatibility - await Promise.all([ - mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)), - mmkvStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)), - ]); - // Ensure a current scope exists to avoid future loads missing the chosen scope - await mmkvStorage.setItem('@user:current', scope); - + const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; + // Write to both scoped key (multi-user aware) and legacy key for backward compatibility + await Promise.all([ + mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)), + mmkvStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)), + ]); + // Ensure a current scope exists to avoid future loads missing the chosen scope + await mmkvStorage.setItem('@user:current', scope); + // Update cache cachedSettings = newSettings; settingsCacheTimestamp = Date.now(); - + setSettings(newSettings); if (__DEV__) console.log(`Setting updated: ${key}`, value); - + // Notify all subscribers that settings have changed (if requested) if (emitEvent) { if (__DEV__) console.log('Emitting settings change event'); diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index 98736736..db0fedc3 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -11,6 +11,7 @@ import { Alert, Platform, Clipboard, + Linking, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; @@ -28,6 +29,8 @@ import { RootStackParamList } from '../navigation/AppNavigator'; 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 { VideoPlayerService } from '../services/videoPlayerService'; import type { DownloadItem } from '../contexts/DownloadsContext'; import { useToast } from '../contexts/ToastContext'; import CustomAlert from '../components/CustomAlert'; @@ -60,7 +63,7 @@ const optimizePosterUrl = (poster: string | undefined | null): string => { // Empty state component const EmptyDownloadsState: React.FC<{ navigation: NavigationProp }> = ({ navigation }) => { const { currentTheme } = useTheme(); - + return ( @@ -76,7 +79,7 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp Downloaded content will appear here for offline viewing - { navigation.navigate('Search'); @@ -129,12 +132,12 @@ const DownloadItemComponent: React.FC<{ const formatBytes = (bytes?: number) => { if (!bytes || bytes <= 0) return '0 B'; - const sizes = ['B','KB','MB','GB','TB']; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); const v = bytes / Math.pow(1024, i); return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${sizes[i]}`; }; - + const getStatusColor = () => { switch (item.status) { case 'downloading': @@ -218,10 +221,10 @@ const DownloadItemComponent: React.FC<{ - {item.title}{item.type === 'series' && item.season && item.episode ? ` S${String(item.season).padStart(2,'0')}E${String(item.episode).padStart(2,'0')}` : ''} + {item.title}{item.type === 'series' && item.season && item.episode ? ` S${String(item.season).padStart(2, '0')}E${String(item.episode).padStart(2, '0')}` : ''} - + {item.type === 'series' && ( S{item.season?.toString().padStart(2, '0')}E{item.episode?.toString().padStart(2, '0')} • {item.episodeTitle} @@ -293,7 +296,7 @@ const DownloadItemComponent: React.FC<{ ]} /> - + {item.progress || 0}% @@ -322,7 +325,7 @@ const DownloadItemComponent: React.FC<{ /> )} - + onRequestRemove(item)} @@ -342,6 +345,7 @@ const DownloadItemComponent: React.FC<{ const DownloadsScreen: React.FC = () => { const navigation = useNavigation>(); const { currentTheme } = useTheme(); + const { settings } = useSettings(); const { top: safeAreaTop } = useSafeAreaInsets(); const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads(); const { showSuccess, showInfo } = useToast(); @@ -394,7 +398,7 @@ const DownloadsScreen: React.FC = () => { setIsRefreshing(false); }, []); - const handleDownloadPress = useCallback((item: DownloadItem) => { + 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.'); @@ -411,33 +415,132 @@ const DownloadsScreen: React.FC = () => { const isMp4 = /\.mp4(\?|$)/i.test(lower); const videoType = isM3u8 ? 'm3u8' : isMpd ? 'mpd' : isMp4 ? 'mp4' : undefined; - // Build episodeId for series progress tracking (format: contentId:season:episode) - const episodeId = item.type === 'series' && item.season && item.episode - ? `${item.contentId}:${item.season}:${item.episode}` - : undefined; + // Use external player if enabled in settings + if (settings.useExternalPlayerForDownloads) { + if (Platform.OS === 'android') { + try { + // Use VideoPlayerService for Android external playback + const success = await VideoPlayerService.playVideo(uri, { + useExternalPlayer: true, + title: item.title, + episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, + episodeNumber: item.type === 'series' && item.season && item.episode ? `S${item.season}E${item.episode}` : undefined, + }); - const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; - navigation.navigate(playerRoute as any, { - uri, - title: item.title, - episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, - season: item.type === 'series' ? item.season : undefined, - episode: item.type === 'series' ? item.episode : undefined, - quality: item.quality, - year: undefined, - streamProvider: 'Downloads', - streamName: item.providerName || 'Offline', - headers: undefined, - forceVlc: Platform.OS === 'android' ? isMkv : false, - id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking - type: item.type, - episodeId: episodeId, // Pass episodeId for series progress tracking - imdbId: (item as any).imdbId || item.contentId, // Use imdbId if available, fallback to contentId - availableStreams: {}, - backdrop: undefined, - videoType, - } as any); - }, [navigation]); + if (success) return; + // Fall through to internal player if external fails + } catch (error) { + console.error('External player failed:', error); + // Fall through to internal player + } + } else if (Platform.OS === 'ios') { + const streamUrl = encodeURIComponent(uri); + let externalPlayerUrls: string[] = []; + + switch (settings.preferredPlayer) { + case 'vlc': + externalPlayerUrls = [ + `vlc://${uri}`, + `vlc-x-callback://x-callback-url/stream?url=${streamUrl}`, + `vlc://${streamUrl}` + ]; + break; + + case 'outplayer': + externalPlayerUrls = [ + `outplayer://${uri}`, + `outplayer://${streamUrl}`, + `outplayer://play?url=${streamUrl}`, + `outplayer://stream?url=${streamUrl}`, + `outplayer://play/browser?url=${streamUrl}` + ]; + break; + + case 'infuse': + externalPlayerUrls = [ + `infuse://x-callback-url/play?url=${streamUrl}`, + `infuse://play?url=${streamUrl}`, + `infuse://${streamUrl}` + ]; + break; + + case 'vidhub': + externalPlayerUrls = [ + `vidhub://play?url=${streamUrl}`, + `vidhub://${streamUrl}` + ]; + break; + + case 'infuse_livecontainer': + const infuseUrls = [ + `infuse://x-callback-url/play?url=${streamUrl}`, + `infuse://play?url=${streamUrl}`, + `infuse://${streamUrl}` + ]; + externalPlayerUrls = infuseUrls.map(infuseUrl => { + const encoded = Buffer.from(infuseUrl).toString('base64'); + return `livecontainer://open-url?url=${encoded}`; + }); + break; + + default: + // Internal logic will handle 'internal' choice + break; + } + + if (settings.preferredPlayer !== 'internal') { + // Try each URL format in sequence + const tryNextUrl = (index: number) => { + if (index >= externalPlayerUrls.length) { + // Fallback to internal player if all external attempts fail + openInternalPlayer(); + return; + } + + const url = externalPlayerUrls[index]; + Linking.openURL(url) + .catch(() => tryNextUrl(index + 1)); + }; + + if (externalPlayerUrls.length > 0) { + tryNextUrl(0); + return; + } + } + } + } + + const openInternalPlayer = () => { + // Build episodeId for series progress tracking (format: contentId:season:episode) + const episodeId = item.type === 'series' && item.season && item.episode + ? `${item.contentId}:${item.season}:${item.episode}` + : undefined; + + const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; + navigation.navigate(playerRoute as any, { + uri, + title: item.title, + episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, + season: item.type === 'series' ? item.season : undefined, + episode: item.type === 'series' ? item.episode : undefined, + quality: item.quality, + year: undefined, + streamProvider: 'Downloads', + streamName: item.providerName || 'Offline', + headers: undefined, + forceVlc: Platform.OS === 'android' ? isMkv : false, + id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking + type: item.type, + episodeId: episodeId, // Pass episodeId for series progress tracking + imdbId: (item as any).imdbId || item.contentId, // Use imdbId if available, fallback to contentId + availableStreams: {}, + backdrop: undefined, + videoType, + } as any); + }; + + openInternalPlayer(); + }, [navigation, settings]); const handleDownloadAction = useCallback((item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => { if (action === 'pause') pauseDownload(item.id); @@ -479,8 +582,8 @@ const DownloadsScreen: React.FC = () => { style={[ styles.filterButton, { - backgroundColor: selectedFilter === filter - ? currentTheme.colors.primary + backgroundColor: selectedFilter === filter + ? currentTheme.colors.primary : currentTheme.colors.elevation1, } ]} @@ -490,8 +593,8 @@ const DownloadsScreen: React.FC = () => { @@ -501,16 +604,16 @@ const DownloadsScreen: React.FC = () => { @@ -534,8 +637,8 @@ const DownloadsScreen: React.FC = () => { styles.header, { backgroundColor: currentTheme.colors.darkBackground, - paddingTop: (Platform.OS === 'android' - ? (StatusBar.currentHeight || 0) + 26 + paddingTop: (Platform.OS === 'android' + ? (StatusBar.currentHeight || 0) + 26 : safeAreaTop + 15) + (isTablet ? 64 : 0), borderBottomColor: currentTheme.colors.border, }, @@ -557,7 +660,7 @@ const DownloadsScreen: React.FC = () => { /> - + {downloads.length > 0 && ( {renderFilterButton('all', 'All', stats.total)} @@ -624,10 +727,10 @@ const DownloadsScreen: React.FC = () => { setShowRemoveAlert(false) }, - { label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: { } }, + { label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: {} }, ]} onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }} /> diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx index 821791f8..a3e57cb8 100644 --- a/src/screens/PlayerSettingsScreen.tsx +++ b/src/screens/PlayerSettingsScreen.tsx @@ -35,7 +35,7 @@ const SettingItem: React.FC = ({ isLast, }) => { const { currentTheme } = useTheme(); - + return ( { Settings - + {/* Empty for now, but ready for future actions */} - + Video Player - @@ -229,7 +229,7 @@ const PlayerSettingsScreen: React.FC = () => { ))} - + { /> + + {/* External Player for Downloads */} + {((Platform.OS === 'android' && settings.useExternalPlayer) || + (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal')) && ( + + + + + + + + External Player for Downloads + + + Play downloaded content in your preferred external player. + + + updateSetting('useExternalPlayerForDownloads', value)} + thumbColor={settings.useExternalPlayerForDownloads ? currentTheme.colors.primary : undefined} + /> + + + )}