added external player selection for downloads

This commit is contained in:
tapframe 2025-12-11 15:00:15 +05:30
parent e160bf6fe0
commit 01953af578
3 changed files with 223 additions and 76 deletions

View file

@ -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: [],
@ -202,7 +204,7 @@ export const useSettings = () => {
if (scoped) {
try {
merged = JSON.parse(scoped);
} catch {}
} catch { }
}
}
@ -231,15 +233,15 @@ 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;

View file

@ -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';
@ -129,7 +132,7 @@ 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]}`;
@ -218,10 +221,10 @@ const DownloadItemComponent: React.FC<{
<MaterialCommunityIcons
name={
item.status === 'completed' ? 'check' :
item.status === 'downloading' ? 'download' :
item.status === 'paused' ? 'pause' :
item.status === 'error' ? 'alert-circle' :
'clock'
item.status === 'downloading' ? 'download' :
item.status === 'paused' ? 'pause' :
item.status === 'error' ? 'alert-circle' :
'clock'
}
size={12}
color="white"
@ -234,7 +237,7 @@ const DownloadItemComponent: React.FC<{
<View style={styles.downloadHeader}>
<View style={styles.titleContainer}>
<Text style={[styles.downloadTitle, { color: currentTheme.colors.text }]} numberOfLines={1}>
{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')}` : ''}
</Text>
</View>
@ -342,6 +345,7 @@ const DownloadItemComponent: React.FC<{
const DownloadsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
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);
@ -624,10 +727,10 @@ const DownloadsScreen: React.FC = () => {
<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?'}
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?'}
actions={[
{ label: 'Cancel', onPress: () => 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); }}
/>

View file

@ -322,6 +322,48 @@ const PlayerSettingsScreen: React.FC = () => {
/>
</View>
</View>
{/* External Player for Downloads */}
{((Platform.OS === 'android' && settings.useExternalPlayer) ||
(Platform.OS === 'ios' && settings.preferredPlayer !== 'internal')) && (
<View style={[styles.settingItem, styles.settingItemBorder, { borderBottomWidth: 0, borderTopWidth: 1, borderTopColor: 'rgba(255,255,255,0.08)' }]}>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons
name="open-in-new"
size={20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingTitle,
{ color: currentTheme.colors.text },
]}
>
External Player for Downloads
</Text>
<Text
style={[
styles.settingDescription,
{ color: currentTheme.colors.textMuted },
]}
>
Play downloaded content in your preferred external player.
</Text>
</View>
<Switch
value={settings.useExternalPlayerForDownloads}
onValueChange={(value) => updateSetting('useExternalPlayerForDownloads', value)}
thumbColor={settings.useExternalPlayerForDownloads ? currentTheme.colors.primary : undefined}
/>
</View>
</View>
)}
</View>
</View>
</ScrollView>