diff --git a/package-lock.json b/package-lock.json index c11c22e5..6ceb6cd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 669517d9..cf26f2fa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 56595865..426f44e6 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -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 ( 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); } diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 17728d86..b1cda313 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -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; @@ -715,6 +717,21 @@ const AppNavigator = () => { }, }} /> + diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx new file mode 100644 index 00000000..f1530dc1 --- /dev/null +++ b/src/screens/PlayerSettingsScreen.tsx @@ -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 = ({ + title, + description, + icon, + isDarkMode, + isSelected, + onPress, + isLast, +}) => ( + + + + + + + + {title} + + {description && ( + + {description} + + )} + + {isSelected && ( + + )} + + +); + +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 ( + + + + + + + + + Video Player + + + + + + + PLAYER SELECTION + + + {playerOptions.map((option, index) => ( + { + if (Platform.OS === 'ios') { + updateSetting('preferredPlayer', option.id as AppSettings['preferredPlayer'], false); + } else { + updateSetting('useExternalPlayer', option.id === 'external', false); + } + }} + isLast={index === playerOptions.length - 1} + /> + ))} + + + + + ); +}; + +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; \ No newline at end of file diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 54b39559..8b0a4cc1 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -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 = ({ children, isDarkMode }) => ( - - {children} +const SettingsCard: React.FC = ({ children, isDarkMode, title }) => ( + + {title && ( + + {title.toUpperCase()} + + )} + + {children} + ); @@ -52,6 +63,7 @@ interface SettingItemProps { isLast?: boolean; onPress?: () => void; isDarkMode: boolean; + badge?: string | number; } const SettingItem: React.FC = ({ @@ -61,7 +73,8 @@ const SettingItem: React.FC = ({ renderControl, isLast = false, onPress, - isDarkMode + isDarkMode, + badge }) => { return ( = ({ { borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' } ]} > - - + + - + {title} @@ -87,6 +103,11 @@ const SettingItem: React.FC = ({ )} + {badge && ( + + {badge} + + )} {renderControl()} @@ -95,17 +116,6 @@ const SettingItem: React.FC = ({ ); }; -const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ title, isDarkMode }) => ( - - - {title} - - -); - const SettingsScreen: React.FC = () => { const { settings, updateSetting } = useSettings(); const systemColorScheme = useColorScheme(); @@ -202,7 +212,7 @@ const SettingsScreen: React.FC = () => { const ChevronRight = () => ( ); @@ -217,14 +227,16 @@ const SettingsScreen: React.FC = () => { Settings + + Reset + - - + { isDarkMode={isDarkMode} renderControl={ChevronRight} onPress={() => navigation.navigate('TraktSettings')} - /> - - - + navigation.navigate('Addons')} + badge={addonCount} /> navigation.navigate('CatalogSettings')} + badge={catalogCount} /> navigation.navigate('HomeScreenSettings')} /> - { isDarkMode={isDarkMode} renderControl={ChevronRight} onPress={() => navigation.navigate('TMDBSettings')} - /> - - - - + navigation.navigate('PlayerSettings')} /> { isLast={true} /> + + + + Version 1.0.0 + + ); @@ -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, }, }); diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index b530639c..faf82843 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -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();