Add PlayerSettings screen and integrate preferred player functionality; update settings to include preferredPlayer option, enhance settings UI with badges, and improve navigation for playback options.

This commit is contained in:
Nayif Noushad 2025-04-22 13:04:10 +05:30
parent 206204998e
commit 12a18c057d
7 changed files with 638 additions and 140 deletions

10
package-lock.json generated
View file

@ -14,6 +14,7 @@
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6",
"@react-native-masked-view/masked-view": "github:react-native-masked-view/masked-view",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
@ -3336,6 +3337,15 @@
"integrity": "sha512-UhLPFeqx0YfPLrEz8ffT3uqAyXWu6iqFjohNsbp4cOU7hnJwg2RXtDnYHoHMr7MOkZDVdlLMdrSrAuzY6KGqrg==",
"license": "MIT"
},
"node_modules/@react-native-masked-view/masked-view": {
"version": "0.3.2",
"resolved": "git+ssh://git@github.com/react-native-masked-view/masked-view.git#14df52650be2441fbf6f2a0308cc54a62e68820c",
"license": "MIT",
"peerDependencies": {
"react": ">=16",
"react-native": ">=0.57"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.76.9",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz",

View file

@ -15,6 +15,7 @@
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6",
"@react-native-masked-view/masked-view": "github:react-native-masked-view/masked-view",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",

View file

@ -28,6 +28,7 @@ export interface AppSettings {
enableBackgroundPlayback: boolean;
cacheLimit: number;
useExternalPlayer: boolean;
preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'external';
showHeroSection: boolean;
featuredContentSource: 'tmdb' | 'catalogs';
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
@ -41,6 +42,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
enableBackgroundPlayback: false,
cacheLimit: 1024,
useExternalPlayer: false,
preferredPlayer: 'internal',
showHeroSection: true,
featuredContentSource: 'tmdb',
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
@ -75,14 +77,17 @@ export const useSettings = () => {
const updateSetting = useCallback(async <K extends keyof AppSettings>(
key: K,
value: AppSettings[K]
value: AppSettings[K],
emitEvent: boolean = true
) => {
const newSettings = { ...settings, [key]: value };
try {
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings));
setSettings(newSettings);
// Notify all subscribers that settings have changed
settingsEmitter.emit();
// Notify all subscribers that settings have changed (if requested)
if (emitEvent) {
settingsEmitter.emit();
}
} catch (error) {
console.error('Failed to save settings:', error);
}

View file

@ -33,6 +33,7 @@ import TMDBSettingsScreen from '../screens/TMDBSettingsScreen';
import HomeScreenSettings from '../screens/HomeScreenSettings';
import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
import TraktSettingsScreen from '../screens/TraktSettingsScreen';
import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
// Stack navigator types
export type RootStackParamList = {
@ -87,6 +88,7 @@ export type RootStackParamList = {
HomeScreenSettings: undefined;
HeroCatalogs: undefined;
TraktSettings: undefined;
PlayerSettings: undefined;
};
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
@ -715,6 +717,21 @@ const AppNavigator = () => {
},
}}
/>
<Stack.Screen
name="PlayerSettings"
component={PlayerSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</PaperProvider>
</>

View file

@ -0,0 +1,313 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
SafeAreaView,
Platform,
useColorScheme,
TouchableOpacity,
StatusBar,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useSettings, AppSettings } from '../hooks/useSettings';
import { colors } from '../styles/colors';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
interface SettingItemProps {
title: string;
description?: string;
icon: string;
isDarkMode: boolean;
isSelected: boolean;
onPress: () => void;
isLast?: boolean;
}
const SettingItem: React.FC<SettingItemProps> = ({
title,
description,
icon,
isDarkMode,
isSelected,
onPress,
isLast,
}) => (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={[
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' },
]}
>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }
]}>
<MaterialIcons
name={icon}
size={20}
color={colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark },
]}
>
{title}
</Text>
{description && (
<Text
style={[
styles.settingDescription,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark },
]}
>
{description}
</Text>
)}
</View>
{isSelected && (
<MaterialIcons
name="check"
size={24}
color={colors.primary}
style={styles.checkIcon}
/>
)}
</View>
</TouchableOpacity>
);
const PlayerSettingsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme();
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
const navigation = useNavigation();
const playerOptions = [
{
id: 'internal',
title: 'Built-in Player',
description: 'Use the app\'s default video player',
icon: 'play-circle-outline',
},
...(Platform.OS === 'ios' ? [
{
id: 'vlc',
title: 'VLC',
description: 'Open streams in VLC media player',
icon: 'video-library',
},
{
id: 'infuse',
title: 'Infuse',
description: 'Open streams in Infuse player',
icon: 'smart-display',
},
{
id: 'outplayer',
title: 'OutPlayer',
description: 'Open streams in OutPlayer',
icon: 'slideshow',
},
{
id: 'vidhub',
title: 'VidHub',
description: 'Open streams in VidHub player',
icon: 'ondemand-video',
},
] : [
{
id: 'external',
title: 'External Player',
description: 'Open streams in your preferred video player',
icon: 'open-in-new',
},
]),
];
const handleBack = () => {
navigation.goBack();
};
return (
<SafeAreaView
style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' },
]}
>
<StatusBar
translucent
backgroundColor="transparent"
barStyle={isDarkMode ? "light-content" : "dark-content"}
/>
<View style={styles.header}>
<TouchableOpacity
onPress={handleBack}
style={styles.backButton}
activeOpacity={0.7}
>
<MaterialIcons
name="arrow-back"
size={24}
color={isDarkMode ? colors.highEmphasis : colors.textDark}
/>
</TouchableOpacity>
<Text
style={[
styles.headerTitle,
{ color: isDarkMode ? colors.highEmphasis : colors.textDark },
]}
>
Video Player
</Text>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark },
]}
>
PLAYER SELECTION
</Text>
<View
style={[
styles.card,
{
backgroundColor: isDarkMode
? colors.elevation2
: colors.white,
},
]}
>
{playerOptions.map((option, index) => (
<SettingItem
key={option.id}
title={option.title}
description={option.description}
icon={option.icon}
isDarkMode={isDarkMode}
isSelected={
Platform.OS === 'ios'
? settings.preferredPlayer === option.id
: settings.useExternalPlayer === (option.id === 'external')
}
onPress={() => {
if (Platform.OS === 'ios') {
updateSetting('preferredPlayer', option.id as AppSettings['preferredPlayer'], false);
} else {
updateSetting('useExternalPlayer', option.id === 'external', false);
}
}}
isLast={index === playerOptions.length - 1}
/>
))}
</View>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
paddingBottom: 8,
},
backButton: {
padding: 8,
marginRight: 16,
borderRadius: 20,
},
headerTitle: {
fontSize: 20,
fontWeight: '600',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 24,
},
section: {
paddingHorizontal: 16,
paddingTop: 24,
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
marginBottom: 8,
paddingHorizontal: 4,
letterSpacing: 0.5,
},
card: {
borderRadius: 12,
overflow: 'hidden',
marginBottom: 24,
shadowColor: 'rgba(0,0,0,0.1)',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.5,
shadowRadius: 4,
elevation: 3,
},
settingItem: {
paddingVertical: 16,
paddingHorizontal: 16,
},
settingItemBorder: {
borderBottomWidth: 1,
},
settingContent: {
flexDirection: 'row',
alignItems: 'center',
},
settingIconContainer: {
width: 36,
height: 36,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
marginRight: 16,
},
settingText: {
flex: 1,
},
settingTitle: {
fontSize: 16,
fontWeight: '500',
marginBottom: 2,
},
settingDescription: {
fontSize: 14,
marginTop: 2,
},
checkIcon: {
marginLeft: 16,
},
});
export default PlayerSettingsScreen;

View file

@ -12,7 +12,7 @@ import {
Alert,
Platform,
Dimensions,
Pressable
Image
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
@ -29,18 +29,29 @@ const { width } = Dimensions.get('window');
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Card component for iOS Fluent design style
// Card component with modern style
interface SettingsCardProps {
children: React.ReactNode;
isDarkMode: boolean;
title?: string;
}
const SettingsCard: React.FC<SettingsCardProps> = ({ children, isDarkMode }) => (
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
{children}
const SettingsCard: React.FC<SettingsCardProps> = ({ children, isDarkMode, title }) => (
<View style={[styles.cardContainer]}>
{title && (
<Text style={[
styles.cardTitle,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
{title.toUpperCase()}
</Text>
)}
<View style={[
styles.card,
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
]}>
{children}
</View>
</View>
);
@ -52,6 +63,7 @@ interface SettingItemProps {
isLast?: boolean;
onPress?: () => void;
isDarkMode: boolean;
badge?: string | number;
}
const SettingItem: React.FC<SettingItemProps> = ({
@ -61,7 +73,8 @@ const SettingItem: React.FC<SettingItemProps> = ({
renderControl,
isLast = false,
onPress,
isDarkMode
isDarkMode,
badge
}) => {
return (
<TouchableOpacity
@ -73,11 +86,14 @@ const SettingItem: React.FC<SettingItemProps> = ({
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
]}
>
<View style={styles.settingIconContainer}>
<MaterialIcons name={icon} size={22} color={colors.primary} />
<View style={[
styles.settingIconContainer,
{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }
]}>
<MaterialIcons name={icon} size={20} color={colors.primary} />
</View>
<View style={styles.settingContent}>
<View style={styles.settingTitleRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
{title}
</Text>
@ -87,6 +103,11 @@ const SettingItem: React.FC<SettingItemProps> = ({
</Text>
)}
</View>
{badge && (
<View style={[styles.badge, { backgroundColor: colors.primary }]}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
)}
</View>
<View style={styles.settingControl}>
{renderControl()}
@ -95,17 +116,6 @@ const SettingItem: React.FC<SettingItemProps> = ({
);
};
const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ title, isDarkMode }) => (
<View style={styles.sectionHeader}>
<Text style={[
styles.sectionHeaderText,
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
]}>
{title}
</Text>
</View>
);
const SettingsScreen: React.FC = () => {
const { settings, updateSetting } = useSettings();
const systemColorScheme = useColorScheme();
@ -202,7 +212,7 @@ const SettingsScreen: React.FC = () => {
const ChevronRight = () => (
<MaterialIcons
name="chevron-right"
size={24}
size={22}
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
/>
);
@ -217,14 +227,16 @@ const SettingsScreen: React.FC = () => {
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
Settings
</Text>
<TouchableOpacity onPress={handleResetSettings} style={styles.resetButton}>
<Text style={[styles.resetButtonText, {color: colors.primary}]}>Reset</Text>
</TouchableOpacity>
</View>
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
<SectionHeader title="USER & ACCOUNT" isDarkMode={isDarkMode} />
<SettingsCard isDarkMode={isDarkMode}>
<SettingsCard isDarkMode={isDarkMode} title="User & Account">
<SettingItem
title="Trakt"
description={isAuthenticated ? `Connected as ${userProfile?.username || 'User'}` : "Not Connected"}
@ -232,53 +244,40 @@ const SettingsScreen: React.FC = () => {
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TraktSettings')}
/>
<SettingItem
title="iCloud Sync"
description="Enabled"
icon="cloud"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
isLast={true}
/>
</SettingsCard>
<SectionHeader title="CONTENT" isDarkMode={isDarkMode} />
<SettingsCard isDarkMode={isDarkMode}>
<SettingsCard isDarkMode={isDarkMode} title="Content">
<SettingItem
title="Addons"
description={addonCount + " installed"}
description="Manage your installed addons"
icon="extension"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('Addons')}
badge={addonCount}
/>
<SettingItem
title="Catalogs"
description={`${catalogCount} ${catalogCount === 1 ? 'catalog' : 'catalogs'} enabled`}
description="Configure content sources"
icon="view-list"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('CatalogSettings')}
badge={catalogCount}
/>
<SettingItem
title="Home Screen"
description="Customize home layout and content"
description="Customize layout and content"
icon="home"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('HomeScreenSettings')}
/>
<SettingItem
title="Folders"
description="0 created"
icon="folder"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
/>
<SettingItem
title="Ratings Source"
description={mdblistKeySet ? "MDBList API Configured" : "MDBList API Not Set"}
description={mdblistKeySet ? "MDBList API Configured" : "Configure MDBList API"}
icon="info-outline"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
@ -291,31 +290,25 @@ const SettingsScreen: React.FC = () => {
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TMDBSettings')}
/>
<SettingItem
title="Resource Filters"
icon="tune"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
/>
<SettingItem
title="AI Features"
description="Not Connected"
icon="auto-awesome"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
isLast={true}
/>
</SettingsCard>
<SectionHeader title="PLAYBACK" isDarkMode={isDarkMode} />
<SettingsCard isDarkMode={isDarkMode}>
<SettingsCard isDarkMode={isDarkMode} title="Playback">
<SettingItem
title="Video Player"
description="Infuse"
description={Platform.OS === 'ios'
? (settings.preferredPlayer === 'internal'
? 'Built-in Player'
: settings.preferredPlayer
? settings.preferredPlayer.toUpperCase()
: 'Built-in Player')
: (settings.useExternalPlayer ? 'External Player' : 'Built-in Player')
}
icon="play-arrow"
isDarkMode={isDarkMode}
renderControl={ChevronRight}
onPress={() => navigation.navigate('PlayerSettings')}
/>
<SettingItem
title="Auto-Filtering"
@ -326,6 +319,12 @@ const SettingsScreen: React.FC = () => {
isLast={true}
/>
</SettingsCard>
<View style={styles.versionContainer}>
<Text style={[styles.versionText, {color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark}]}>
Version 1.0.0
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
@ -339,82 +338,117 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 12,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
headerTitle: {
fontSize: 34,
fontSize: 32,
fontWeight: '700',
letterSpacing: 0.5,
},
resetButton: {
paddingVertical: 6,
paddingHorizontal: 12,
},
resetButtonText: {
fontSize: 15,
fontWeight: '600',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 32,
},
sectionHeader: {
paddingHorizontal: 16,
paddingTop: 20,
paddingBottom: 8,
cardContainer: {
marginBottom: 20,
},
sectionHeaderText: {
fontSize: 12,
cardTitle: {
fontSize: 13,
fontWeight: '600',
letterSpacing: 0.8,
marginLeft: 16,
marginBottom: 8,
},
card: {
marginHorizontal: 16,
borderRadius: 12,
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 2,
elevation: 3,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingVertical: 12,
paddingHorizontal: 16,
borderBottomWidth: 0.5,
minHeight: 44,
minHeight: 58,
},
settingItemBorder: {
// Border styling handled directly in the component with borderBottomWidth
},
settingIconContainer: {
marginRight: 12,
width: 24,
height: 24,
marginRight: 16,
width: 36,
height: 36,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
settingContent: {
flex: 1,
marginRight: 8,
flexDirection: 'row',
alignItems: 'center',
},
settingTextContainer: {
flex: 1,
},
settingTitleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
},
settingTitle: {
fontSize: 15,
fontWeight: '400',
flex: 1,
fontSize: 16,
fontWeight: '500',
marginBottom: 3,
},
settingDescription: {
fontSize: 14,
opacity: 0.7,
textAlign: 'right',
flexShrink: 1,
maxWidth: '60%',
opacity: 0.8,
},
settingControl: {
justifyContent: 'center',
alignItems: 'center',
paddingLeft: 8,
paddingLeft: 12,
},
badge: {
height: 22,
minWidth: 22,
borderRadius: 11,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 6,
marginRight: 8,
},
badgeText: {
color: 'white',
fontSize: 12,
fontWeight: '600',
},
versionContainer: {
alignItems: 'center',
justifyContent: 'center',
marginTop: 10,
marginBottom: 20,
},
versionText: {
fontSize: 14,
},
});

View file

@ -12,7 +12,8 @@ import {
ScrollView,
StatusBar,
Alert,
Dimensions
Dimensions,
Linking
} from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { RouteProp } from '@react-navigation/native';
@ -343,6 +344,22 @@ export const StreamsScreen = () => {
);
}, [selectedEpisode, groupedEpisodes, id]);
const navigateToPlayer = useCallback((stream: Stream) => {
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
season: type === 'series' ? currentEpisode?.season_number : undefined,
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
year: metadata?.year,
streamProvider: stream.name,
id,
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
});
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode]);
// Update handleStreamPress
const handleStreamPress = useCallback(async (stream: Stream) => {
try {
@ -350,63 +367,164 @@ export const StreamsScreen = () => {
logger.log('handleStreamPress called with stream:', {
url: stream.url,
behaviorHints: stream.behaviorHints,
useExternalPlayer: settings.useExternalPlayer
useExternalPlayer: settings.useExternalPlayer,
preferredPlayer: settings.preferredPlayer
});
// Check if external player is enabled in settings
if (settings.useExternalPlayer) {
logger.log('Using external player for URL:', stream.url);
// Use VideoPlayerService to launch external player
const videoPlayerService = VideoPlayerService;
const launched = await videoPlayerService.playVideo(stream.url, {
useExternalPlayer: true,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
episodeNumber: type === 'series' ? `S${currentEpisode?.season_number}E${currentEpisode?.episode_number}` : undefined,
releaseDate: metadata?.year?.toString(),
});
if (!launched) {
logger.log('External player launch failed, falling back to built-in player');
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
season: type === 'series' ? currentEpisode?.season_number : undefined,
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
year: metadata?.year,
streamProvider: stream.name,
id,
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
});
// For iOS, try to open with the preferred external player
if (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal') {
try {
// Format the URL for the selected player
const streamUrl = encodeURIComponent(stream.url);
let externalPlayerUrls: string[] = [];
// Configure URL formats based on the selected player
switch (settings.preferredPlayer) {
case 'vlc':
externalPlayerUrls = [
`vlc://${stream.url}`,
`vlc-x-callback://x-callback-url/stream?url=${streamUrl}`,
`vlc://${streamUrl}`
];
break;
case 'outplayer':
externalPlayerUrls = [
`outplayer://${stream.url}`,
`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;
default:
// If no matching player or the setting is somehow invalid, use internal player
navigateToPlayer(stream);
return;
}
console.log(`Attempting to open stream in ${settings.preferredPlayer}`);
// Try each URL format in sequence
const tryNextUrl = (index: number) => {
if (index >= externalPlayerUrls.length) {
console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`);
// Try direct URL as last resort
Linking.openURL(stream.url)
.then(() => console.log('Opened with direct URL'))
.catch(() => {
console.log('Direct URL failed, falling back to built-in player');
navigateToPlayer(stream);
});
return;
}
const url = externalPlayerUrls[index];
console.log(`Trying ${settings.preferredPlayer} URL format ${index + 1}: ${url}`);
Linking.openURL(url)
.then(() => console.log(`Successfully opened stream with ${settings.preferredPlayer} format ${index + 1}`))
.catch(err => {
console.log(`Format ${index + 1} failed: ${err.message}`, err);
tryNextUrl(index + 1);
});
};
// Start with the first URL format
tryNextUrl(0);
} catch (error) {
console.error(`Error with ${settings.preferredPlayer}:`, error);
// Fallback to the built-in player
navigateToPlayer(stream);
}
} else {
// Use built-in player
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
season: type === 'series' ? currentEpisode?.season_number : undefined,
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
year: metadata?.year,
streamProvider: stream.name,
id,
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
});
}
// For Android with external player preference
else if (Platform.OS === 'android' && settings.useExternalPlayer) {
try {
console.log('Opening stream with Android native app chooser');
// For Android, determine if the URL is a direct http/https URL or a magnet link
const isMagnet = stream.url.startsWith('magnet:');
if (isMagnet) {
// For magnet links, open directly which will trigger the torrent app chooser
console.log('Opening magnet link directly');
Linking.openURL(stream.url)
.then(() => console.log('Successfully opened magnet link'))
.catch(err => {
console.error('Failed to open magnet link:', err);
// No good fallback for magnet links
navigateToPlayer(stream);
});
} else {
// For direct video URLs, use the S.Browser.ACTION_VIEW approach
// This is a more reliable way to force Android to show all video apps
// Strip query parameters if they exist as they can cause issues with some apps
let cleanUrl = stream.url;
if (cleanUrl.includes('?')) {
cleanUrl = cleanUrl.split('?')[0];
}
// Create an Android intent URL that forces the chooser
// Set component=null to ensure chooser is shown
// Set action=android.intent.action.VIEW to open the content
const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`;
console.log(`Using intent URL: ${intentUrl}`);
Linking.openURL(intentUrl)
.then(() => console.log('Successfully opened with intent URL'))
.catch(err => {
console.error('Failed to open with intent URL:', err);
// First fallback: Try direct URL with regular Linking API
console.log('Trying plain URL as fallback');
Linking.openURL(stream.url)
.then(() => console.log('Opened with direct URL'))
.catch(directErr => {
console.error('Failed to open direct URL:', directErr);
// Final fallback: Use built-in player
console.log('All external player attempts failed, using built-in player');
navigateToPlayer(stream);
});
});
}
} catch (error) {
console.error('Error with external player:', error);
// Fallback to the built-in player
navigateToPlayer(stream);
}
}
else {
// For internal player or if other options failed, use the built-in player
navigateToPlayer(stream);
}
}
} catch (error) {
logger.error('Stream error:', error);
Alert.alert(
'Playback Error',
error instanceof Error ? error.message : 'An error occurred while playing the video'
);
console.error('Error in handleStreamPress:', error);
// Final fallback: Use built-in player
navigateToPlayer(stream);
}
}, [metadata, type, currentEpisode, navigation, settings.useExternalPlayer]);
}, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]);
const filterItems = useMemo(() => {
const installedAddons = stremioService.getInstalledAddons();