From 046c9e3f9787ac93cd45e1e6cf2a1edbfa6fd0fd Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 11 Jun 2025 02:10:10 +0530 Subject: [PATCH] Enhance modals with fixed dimensions and improved layout This update introduces fixed dimensions for the AudioTrackModal, SourcesModal, and SubtitleModals, ensuring consistent sizing across different screen sizes. The layout has been refined to improve visual clarity and usability, including adjustments to scroll view heights and modal styles. Additionally, the integration of a new XPRIME source in the metadata handling enhances the overall streaming experience by prioritizing this source in the selection process. --- .../player/modals/AudioTrackModal.tsx | 333 +++++------ src/components/player/modals/SourcesModal.tsx | 24 +- .../player/modals/SubtitleModals.tsx | 30 +- src/hooks/useMetadata.ts | 75 ++- src/navigation/AppNavigator.tsx | 17 + src/screens/InternalProvidersSettings.tsx | 515 ++++++++++++++++++ src/screens/SettingsScreen.tsx | 8 +- src/screens/StreamsScreen.tsx | 62 ++- src/services/hdrezkaService.ts | 20 +- src/services/xprimeService.ts | 380 +++++++++++++ 10 files changed, 1235 insertions(+), 229 deletions(-) create mode 100644 src/screens/InternalProvidersSettings.tsx create mode 100644 src/services/xprimeService.ts diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx index c8ec13f..63805e1 100644 --- a/src/components/player/modals/AudioTrackModal.tsx +++ b/src/components/player/modals/AudioTrackModal.tsx @@ -36,6 +36,10 @@ interface AudioTrackModalProps { const { width, height } = Dimensions.get('window'); +// Fixed dimensions for the modal +const MODAL_WIDTH = Math.min(width - 32, 520); +const MODAL_MAX_HEIGHT = height * 0.85; + const AudioBadge = ({ text, color, @@ -152,14 +156,16 @@ export const AudioTrackModal: React.FC = ({ = ({ borderRadius: 28, overflow: 'hidden', backgroundColor: 'rgba(26, 26, 26, 0.8)', + width: '100%', + height: '100%', }} > {/* Header */} @@ -190,6 +198,7 @@ export const AudioTrackModal: React.FC = ({ justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.1)', + width: '100%', }} > = ({ {/* Content */} - {/* Audio Tracks Section */} - - - + {vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => ( + - - - Available Audio Tracks - - - Select your preferred audio language - - - - - {/* Audio Tracks List */} - {vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => { - const isSelected = selectedAudioTrack === track.id; - - return ( - + { + selectAudioTrack(track.id); + handleClose(); + }} + activeOpacity={0.85} > - { - selectAudioTrack(track.id); - handleClose(); - }} - activeOpacity={0.85} - > - - - + + + - - {getTrackDisplayName(track)} - - - {isSelected && ( - - - - ACTIVE - - - )} - + {getTrackDisplayName(track)} + - {(track.name && track.language) && ( - - {track.name} - + {selectedAudioTrack === track.id && ( + + + + ACTIVE + + )} - - - - {track.language && ( - - )} - - {isSelected ? ( - - - - ) : ( - + + {track.language && ( + )} - - - ); - }) : ( + + + {selectedAudioTrack === track.id ? ( + + + + ) : ( + + )} + + + + + )) : ( = ({ alignItems: 'center', borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.05)', + width: '100%', }} > @@ -469,7 +420,7 @@ export const AudioTrackModal: React.FC = ({ textAlign: 'center', letterSpacing: -0.3, }}> - No audio tracks available + No audio tracks found = ({ textAlign: 'center', lineHeight: 20, }}> - This content doesn't have multiple audio tracks.{'\n'}The default audio will be used. + No audio tracks are available for this content.{'\n'}Try a different source or check your connection. )} - + diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx index 3bfd725..0c232e6 100644 --- a/src/components/player/modals/SourcesModal.tsx +++ b/src/components/player/modals/SourcesModal.tsx @@ -38,6 +38,10 @@ interface SourcesModalProps { const { width, height } = Dimensions.get('window'); +// Fixed dimensions for the modal +const MODAL_WIDTH = Math.min(width - 32, 520); +const MODAL_MAX_HEIGHT = height * 0.85; + const QualityIndicator = ({ quality }: { quality: string | null }) => { if (!quality) return null; @@ -229,14 +233,16 @@ const SourcesModal: React.FC = ({ = ({ borderRadius: 28, overflow: 'hidden', backgroundColor: 'rgba(26, 26, 26, 0.8)', + width: '100%', + height: '100%', }} > {/* Header */} @@ -267,6 +275,7 @@ const SourcesModal: React.FC = ({ justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.1)', + width: '100%', }} > = ({ {/* Content */} @@ -336,6 +347,7 @@ const SourcesModal: React.FC = ({ layout={Layout.springify()} style={{ marginBottom: streams.length > 0 ? 32 : 0, + width: '100%', }} > {/* Provider Header */} @@ -346,6 +358,7 @@ const SourcesModal: React.FC = ({ paddingBottom: 12, borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.08)', + width: '100%', }}> = ({ {/* Streams Grid */} - + {streams.map((stream, index) => { const quality = getQualityFromTitle(stream.title); const isSelected = isStreamSelected(stream); @@ -415,6 +428,7 @@ const SourcesModal: React.FC = ({ key={`${stream.url}-${index}`} entering={FadeInDown.duration(300).delay((providerIndex * 80) + (index * 40))} layout={Layout.springify()} + style={{ width: '100%' }} > = ({ shadowOpacity: isSelected ? 0.3 : 0.1, shadowRadius: isSelected ? 12 : 6, transform: [{ scale: isSelected ? 1.02 : 1 }], + width: '100%', }} onPress={() => handleStreamSelect(stream)} disabled={isChangingSource || isSelected} @@ -442,6 +457,7 @@ const SourcesModal: React.FC = ({ flexDirection: 'row', alignItems: 'flex-start', justifyContent: 'space-between', + width: '100%', }}> {/* Stream Info */} diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx index a667189..cf5e247 100644 --- a/src/components/player/modals/SubtitleModals.tsx +++ b/src/components/player/modals/SubtitleModals.tsx @@ -49,6 +49,10 @@ interface SubtitleModalsProps { const { width, height } = Dimensions.get('window'); +// Fixed dimensions for the modals +const MODAL_WIDTH = Math.min(width - 32, 520); +const MODAL_MAX_HEIGHT = height * 0.85; + const SubtitleBadge = ({ text, color, @@ -206,14 +210,16 @@ export const SubtitleModals: React.FC = ({ = ({ borderRadius: 28, overflow: 'hidden', backgroundColor: 'rgba(26, 26, 26, 0.8)', + width: '100%', + height: '100%', }} > {/* Header */} @@ -244,6 +252,7 @@ export const SubtitleModals: React.FC = ({ justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.1)', + width: '100%', }} > = ({ {/* Content */} @@ -890,14 +901,16 @@ export const SubtitleModals: React.FC = ({ = ({ borderRadius: 28, overflow: 'hidden', backgroundColor: 'rgba(26, 26, 26, 0.8)', + width: '100%', + height: '100%', }} > {/* Header */} @@ -928,6 +943,7 @@ export const SubtitleModals: React.FC = ({ justifyContent: 'space-between', borderBottomWidth: 1, borderBottomColor: 'rgba(255, 255, 255, 0.1)', + width: '100%', }} > = ({ {/* Content */} diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index ee0a895..c6941c1 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -4,6 +4,7 @@ import { catalogService } from '../services/catalogService'; import { stremioService } from '../services/stremioService'; import { tmdbService } from '../services/tmdbService'; import { hdrezkaService } from '../services/hdrezkaService'; +import { xprimeService } from '../services/xprimeService'; import { cacheService } from '../services/cacheService'; import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata'; import { TMDBService } from '../services/tmdbService'; @@ -215,6 +216,43 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = } }; + const processXprimeSource = async (type: string, id: string, season?: number, episode?: number, isEpisode = false) => { + const sourceStartTime = Date.now(); + const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; + const sourceName = 'xprime'; + + logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`); + + try { + const streams = await xprimeService.getStreams( + id, + type, + season, + episode + ); + + const processTime = Date.now() - sourceStartTime; + + if (streams && streams.length > 0) { + logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams after ${processTime}ms`); + + // Format response similar to Stremio format for the UI + return { + 'xprime': { + addonName: 'XPRIME', + streams + } + }; + } else { + logger.log(`⚠️ [${logPrefix}:${sourceName}] No streams found after ${processTime}ms`); + return {}; + } + } catch (error) { + logger.error(`❌ [${logPrefix}:${sourceName}] Error:`, error); + return {}; + } + }; + const processExternalSource = async (sourceType: string, promise: Promise, isEpisode = false) => { const sourceStartTime = Date.now(); const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; @@ -230,7 +268,13 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = const updateState = (prevState: GroupedStreams) => { logger.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`); - return { ...prevState, ...result }; + + // If this is XPRIME, put it first; otherwise append to the end + if (sourceType === 'xprime') { + return { ...result, ...prevState }; + } else { + return { ...prevState, ...result }; + } }; if (isEpisode) { @@ -641,18 +685,21 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = // Start Stremio request using the callback method processStremioSource(type, id, false); - // Add HDRezka source + // Add Xprime source (PRIMARY) + const xprimePromise = processExternalSource('xprime', processXprimeSource(type, id), false); + + // Add HDRezka source const hdrezkaPromise = processExternalSource('hdrezka', processHDRezkaSource(type, id), false); - // Include HDRezka in fetchPromises array - const fetchPromises: Promise[] = [hdrezkaPromise]; + // Include Xprime and HDRezka in fetchPromises array (Xprime first) + const fetchPromises: Promise[] = [xprimePromise, hdrezkaPromise]; // Wait only for external promises now const results = await Promise.allSettled(fetchPromises); const totalTime = Date.now() - startTime; console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - const sourceTypes: string[] = ['hdrezka']; + const sourceTypes: string[] = ['xprime', 'hdrezka']; results.forEach((result, index) => { const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; console.log(`📊 [loadStreams:${source}] Status: ${result.status}`); @@ -723,22 +770,26 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn = // Start Stremio request using the callback method processStremioSource('series', episodeId, true); - // Add HDRezka source for episodes - const seasonNum = parseInt(season, 10); - const episodeNum = parseInt(episode, 10); - const hdrezkaPromise = processExternalSource('hdrezka', - processHDRezkaSource('series', id, seasonNum, episodeNum, true), + // Add Xprime source for episodes (PRIMARY) + const xprimeEpisodePromise = processExternalSource('xprime', + processXprimeSource('series', id, parseInt(season), parseInt(episode), true), true ); - const fetchPromises: Promise[] = [hdrezkaPromise]; + // Add HDRezka source for episodes + const hdrezkaEpisodePromise = processExternalSource('hdrezka', + processHDRezkaSource('series', id, parseInt(season), parseInt(episode), true), + true + ); + + const fetchPromises: Promise[] = [xprimeEpisodePromise, hdrezkaEpisodePromise]; // Wait only for external promises now const results = await Promise.allSettled(fetchPromises); const totalTime = Date.now() - startTime; console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - const sourceTypes: string[] = ['hdrezka']; + const sourceTypes: string[] = ['xprime', 'hdrezka']; results.forEach((result, index) => { const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 0892a54..2407b9a 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -39,6 +39,7 @@ import PlayerSettingsScreen from '../screens/PlayerSettingsScreen'; import LogoSourceSettings from '../screens/LogoSourceSettings'; import ThemeScreen from '../screens/ThemeScreen'; import ProfilesScreen from '../screens/ProfilesScreen'; +import InternalProvidersSettings from '../screens/InternalProvidersSettings'; // Stack navigator types export type RootStackParamList = { @@ -100,6 +101,7 @@ export type RootStackParamList = { LogoSourceSettings: undefined; ThemeSettings: undefined; ProfilesSettings: undefined; + InternalProvidersSettings: undefined; }; export type RootStackNavigationProp = NativeStackNavigationProp; @@ -1012,6 +1014,21 @@ const AppNavigator = () => { }, }} /> + diff --git a/src/screens/InternalProvidersSettings.tsx b/src/screens/InternalProvidersSettings.tsx new file mode 100644 index 0000000..69845b8 --- /dev/null +++ b/src/screens/InternalProvidersSettings.tsx @@ -0,0 +1,515 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + SafeAreaView, + Platform, + TouchableOpacity, + StatusBar, + Switch, + Alert, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSettings } from '../hooks/useSettings'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import { useTheme } from '../contexts/ThemeContext'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +interface SettingItemProps { + title: string; + description?: string; + icon: string; + value: boolean; + onValueChange: (value: boolean) => void; + isLast?: boolean; + badge?: string; +} + +const SettingItem: React.FC = ({ + title, + description, + icon, + value, + onValueChange, + isLast, + badge, +}) => { + const { currentTheme } = useTheme(); + + return ( + + + + + + + + + {title} + + {badge && ( + + {badge} + + )} + + {description && ( + + {description} + + )} + + + + + ); +}; + +const InternalProvidersSettings: React.FC = () => { + const { settings, updateSetting } = useSettings(); + const { currentTheme } = useTheme(); + const navigation = useNavigation(); + + // Individual provider states + const [xprimeEnabled, setXprimeEnabled] = useState(true); + const [hdrezkaEnabled, setHdrezkaEnabled] = useState(true); + + // Load individual provider settings + useEffect(() => { + const loadProviderSettings = async () => { + try { + const xprimeSettings = await AsyncStorage.getItem('xprime_settings'); + const hdrezkaSettings = await AsyncStorage.getItem('hdrezka_settings'); + + if (xprimeSettings) { + const parsed = JSON.parse(xprimeSettings); + setXprimeEnabled(parsed.enabled !== false); + } + + if (hdrezkaSettings) { + const parsed = JSON.parse(hdrezkaSettings); + setHdrezkaEnabled(parsed.enabled !== false); + } + } catch (error) { + console.error('Error loading provider settings:', error); + } + }; + + loadProviderSettings(); + }, []); + + const handleBack = () => { + navigation.goBack(); + }; + + const handleMasterToggle = useCallback((enabled: boolean) => { + if (!enabled) { + Alert.alert( + 'Disable Internal Providers', + 'This will disable all built-in streaming providers (XPRIME, HDRezka). You can still use external Stremio addons.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Disable', + style: 'destructive', + onPress: () => { + updateSetting('enableInternalProviders', false); + } + } + ] + ); + } else { + updateSetting('enableInternalProviders', true); + } + }, [updateSetting]); + + const handleXprimeToggle = useCallback(async (enabled: boolean) => { + setXprimeEnabled(enabled); + try { + await AsyncStorage.setItem('xprime_settings', JSON.stringify({ enabled })); + } catch (error) { + console.error('Error saving XPRIME settings:', error); + } + }, []); + + const handleHdrezkaToggle = useCallback(async (enabled: boolean) => { + setHdrezkaEnabled(enabled); + try { + await AsyncStorage.setItem('hdrezka_settings', JSON.stringify({ enabled })); + } catch (error) { + console.error('Error saving HDRezka settings:', error); + } + }, []); + + return ( + + + + + + + + + Internal Providers + + + + + {/* Master Toggle Section */} + + + MASTER CONTROL + + + + + + + {/* Individual Providers Section */} + {settings.enableInternalProviders && ( + + + INDIVIDUAL PROVIDERS + + + + + + + )} + + {/* Information Section */} + + + INFORMATION + + + + + + About Internal Providers + + + Internal providers are built directly into the app and don't require separate addon installation. They complement your Stremio addons by providing additional streaming sources. + + + + + + No addon installation required + + + + + + Multiple quality options + + + + + + Fast and reliable streaming + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, + paddingBottom: 16, + }, + backButton: { + padding: 8, + marginRight: 8, + }, + headerTitle: { + fontSize: 24, + fontWeight: '700', + flex: 1, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: 16, + paddingBottom: 100, + }, + section: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 13, + fontWeight: '600', + letterSpacing: 0.8, + marginBottom: 8, + paddingHorizontal: 4, + }, + card: { + borderRadius: 16, + overflow: 'hidden', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + settingItem: { + padding: 16, + borderBottomWidth: 0.5, + }, + settingItemBorder: { + borderBottomWidth: 0.5, + }, + settingContent: { + flexDirection: 'row', + alignItems: 'center', + }, + settingIconContainer: { + marginRight: 16, + width: 36, + height: 36, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + settingText: { + flex: 1, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + }, + settingTitle: { + fontSize: 16, + fontWeight: '500', + }, + settingDescription: { + fontSize: 14, + opacity: 0.8, + lineHeight: 20, + }, + badge: { + height: 18, + minWidth: 18, + borderRadius: 9, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 6, + marginLeft: 8, + }, + badgeText: { + color: 'white', + fontSize: 10, + fontWeight: '600', + }, + infoCard: { + borderRadius: 16, + padding: 16, + flexDirection: 'row', + borderWidth: 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + infoIcon: { + marginRight: 12, + marginTop: 2, + }, + infoContent: { + flex: 1, + }, + infoTitle: { + fontSize: 16, + fontWeight: '600', + marginBottom: 8, + }, + infoDescription: { + fontSize: 14, + lineHeight: 20, + marginBottom: 12, + }, + featureList: { + gap: 6, + }, + featureItem: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + featureText: { + fontSize: 14, + flex: 1, + }, +}); + +export default InternalProvidersSettings; \ No newline at end of file diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 9dbe102..bc7471c 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -410,12 +410,8 @@ const SettingsScreen: React.FC = () => { title="Internal Providers" description="Enable or disable built-in providers like HDRezka" icon="source" - renderControl={() => ( - updateSetting('enableInternalProviders', value)} - /> - )} + renderControl={ChevronRight} + onPress={() => navigation.navigate('InternalProvidersSettings')} /> { { id: 'all', name: 'All Providers' }, ...Array.from(allProviders) .sort((a, b) => { - // Always put HDRezka at the top + // Always put XPRIME at the top (primary source) + if (a === 'xprime') return -1; + if (b === 'xprime') return 1; + + // Then put HDRezka second if (a === 'hdrezka') return -1; if (b === 'hdrezka') return 1; - // Then sort Stremio addons by installation order + // Then sort by Stremio addon installation order const indexA = installedAddons.findIndex(addon => addon.id === a); const indexB = installedAddons.findIndex(addon => addon.id === b); @@ -789,8 +793,44 @@ export const StreamsScreen = () => { // Helper function to extract quality as a number for sorting const getQualityNumeric = (title: string | undefined): number => { if (!title) return 0; - const match = title.match(/(\d+)p/); - return match ? parseInt(match[1], 10) : 0; + + // First try to match quality with "p" (e.g., "1080p", "720p") + const matchWithP = title.match(/(\d+)p/i); + if (matchWithP) { + return parseInt(matchWithP[1], 10); + } + + // Then try to match standalone quality numbers at the end of the title + // This handles XPRIME format where quality is just "1080", "720", etc. + const matchAtEnd = title.match(/\b(\d{3,4})\s*$/); + if (matchAtEnd) { + const quality = parseInt(matchAtEnd[1], 10); + // Only return if it looks like a video quality (between 240 and 8000) + if (quality >= 240 && quality <= 8000) { + return quality; + } + } + + // Try to match quality patterns anywhere in the title with common formats + const qualityPatterns = [ + /\b(\d{3,4})p\b/i, // 1080p, 720p, etc. + /\b(\d{3,4})\s*$/, // 1080, 720 at end + /\s(\d{3,4})\s/, // 720 surrounded by spaces + /-\s*(\d{3,4})\s*$/, // -720 at end + /\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i // specific quality values + ]; + + for (const pattern of qualityPatterns) { + const match = title.match(pattern); + if (match) { + const quality = parseInt(match[1], 10); + if (quality >= 240 && quality <= 8000) { + return quality; + } + } + } + + return 0; }; // Filter streams by selected provider - only if not "all" @@ -804,7 +844,11 @@ export const StreamsScreen = () => { return addonId === selectedProvider; }) .sort(([addonIdA], [addonIdB]) => { - // Always put HDRezka at the top + // Always put XPRIME at the top (primary source) + if (addonIdA === 'xprime') return -1; + if (addonIdB === 'xprime') return 1; + + // Then put HDRezka second if (addonIdA === 'hdrezka') return -1; if (addonIdB === 'hdrezka') return 1; @@ -825,6 +869,14 @@ export const StreamsScreen = () => { const qualityB = getQualityNumeric(b.title); return qualityB - qualityA; // Sort descending (e.g., 1080p before 720p) }); + } else if (addonId === 'xprime') { + // Sort XPRIME streams by quality in descending order (highest quality first) + // For XPRIME, quality is in the 'name' field + sortedProviderStreams = [...providerStreams].sort((a, b) => { + const qualityA = getQualityNumeric(a.name); + const qualityB = getQualityNumeric(b.name); + return qualityB - qualityA; // Sort descending (e.g., 1080 before 720) + }); } return { title: addonName, diff --git a/src/services/hdrezkaService.ts b/src/services/hdrezkaService.ts index 98b39e3..0945924 100644 --- a/src/services/hdrezkaService.ts +++ b/src/services/hdrezkaService.ts @@ -420,15 +420,25 @@ class HDRezkaService { try { logger.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`); - // First check if internal providers are enabled - const settingsJson = await AsyncStorage.getItem('app_settings'); - if (settingsJson) { - const settings = JSON.parse(settingsJson); - if (settings.enableInternalProviders === false) { + // Check if internal providers are enabled globally + const appSettingsJson = await AsyncStorage.getItem('app_settings'); + if (appSettingsJson) { + const appSettings = JSON.parse(appSettingsJson); + if (appSettings.enableInternalProviders === false) { logger.log('[HDRezka] Internal providers are disabled in settings, skipping HDRezka'); return []; } } + + // Check if HDRezka specifically is enabled + const hdrezkaSettingsJson = await AsyncStorage.getItem('hdrezka_settings'); + if (hdrezkaSettingsJson) { + const hdrezkaSettings = JSON.parse(hdrezkaSettingsJson); + if (hdrezkaSettings.enabled === false) { + logger.log('[HDRezka] HDRezka provider is disabled in settings, skipping HDRezka'); + return []; + } + } // First, extract the actual title from TMDB if this is an ID let title = mediaId; diff --git a/src/services/xprimeService.ts b/src/services/xprimeService.ts new file mode 100644 index 0000000..ab33583 --- /dev/null +++ b/src/services/xprimeService.ts @@ -0,0 +1,380 @@ +import { logger } from '../utils/logger'; +import { Stream } from '../types/metadata'; +import { tmdbService } from './tmdbService'; +import axios from 'axios'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as FileSystem from 'expo-file-system'; +import * as Crypto from 'expo-crypto'; + +// Use node-fetch if available, otherwise fallback to global fetch +let fetchImpl: typeof fetch; +try { + // @ts-ignore + fetchImpl = require('node-fetch'); +} catch { + fetchImpl = fetch; +} + +// Constants +const MAX_RETRIES_XPRIME = 3; +const RETRY_DELAY_MS_XPRIME = 1000; + +// Use app's cache directory for React Native +const CACHE_DIR = `${FileSystem.cacheDirectory}xprime/`; + +const BROWSER_HEADERS_XPRIME = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + 'Sec-Ch-Ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"Windows"', + 'Connection': 'keep-alive' +}; + +interface XprimeStream { + url: string; + quality: string; + title: string; + provider: string; + codecs: string[]; + size: string; +} + +class XprimeService { + private MAX_RETRIES = 3; + private RETRY_DELAY = 1000; // 1 second + + // Ensure cache directories exist + private async ensureCacheDir(dirPath: string) { + try { + const dirInfo = await FileSystem.getInfoAsync(dirPath); + if (!dirInfo.exists) { + await FileSystem.makeDirectoryAsync(dirPath, { intermediates: true }); + } + } catch (error) { + logger.error(`[XPRIME] Warning: Could not create cache directory ${dirPath}:`, error); + } + } + + // Cache helpers + private async getFromCache(cacheKey: string, subDir: string = ''): Promise { + try { + const fullPath = `${CACHE_DIR}${subDir}/${cacheKey}`; + const fileInfo = await FileSystem.getInfoAsync(fullPath); + + if (fileInfo.exists) { + const data = await FileSystem.readAsStringAsync(fullPath); + logger.log(`[XPRIME] CACHE HIT for: ${subDir}/${cacheKey}`); + try { + return JSON.parse(data); + } catch (e) { + return data; + } + } + return null; + } catch (error) { + logger.error(`[XPRIME] CACHE READ ERROR for ${cacheKey}:`, error); + return null; + } + } + + private async saveToCache(cacheKey: string, content: any, subDir: string = '') { + try { + const fullSubDir = `${CACHE_DIR}${subDir}/`; + await this.ensureCacheDir(fullSubDir); + + const fullPath = `${fullSubDir}${cacheKey}`; + const dataToSave = typeof content === 'string' ? content : JSON.stringify(content, null, 2); + + await FileSystem.writeAsStringAsync(fullPath, dataToSave); + logger.log(`[XPRIME] SAVED TO CACHE: ${subDir}/${cacheKey}`); + } catch (error) { + logger.error(`[XPRIME] CACHE WRITE ERROR for ${cacheKey}:`, error); + } + } + + // Helper function to fetch stream size using a HEAD request + private async fetchStreamSize(url: string): Promise { + const cacheSubDir = 'xprime_stream_sizes'; + + // Create a hash of the URL to use as the cache key + const urlHash = await Crypto.digestStringAsync( + Crypto.CryptoDigestAlgorithm.MD5, + url, + { encoding: Crypto.CryptoEncoding.HEX } + ); + const urlCacheKey = `${urlHash}.txt`; + + const cachedSize = await this.getFromCache(urlCacheKey, cacheSubDir); + if (cachedSize !== null) { + return cachedSize; + } + + try { + // For m3u8, Content-Length is for the playlist file, not the stream segments + if (url.toLowerCase().includes('.m3u8')) { + await this.saveToCache(urlCacheKey, 'Playlist (size N/A)', cacheSubDir); + return 'Playlist (size N/A)'; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); // 5-second timeout + + try { + const response = await fetchImpl(url, { + method: 'HEAD', + signal: controller.signal + }); + clearTimeout(timeoutId); + + const contentLength = response.headers.get('content-length'); + if (contentLength) { + const sizeInBytes = parseInt(contentLength, 10); + if (!isNaN(sizeInBytes)) { + let formattedSize; + if (sizeInBytes < 1024) formattedSize = `${sizeInBytes} B`; + else if (sizeInBytes < 1024 * 1024) formattedSize = `${(sizeInBytes / 1024).toFixed(2)} KB`; + else if (sizeInBytes < 1024 * 1024 * 1024) formattedSize = `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`; + else formattedSize = `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + + await this.saveToCache(urlCacheKey, formattedSize, cacheSubDir); + return formattedSize; + } + } + await this.saveToCache(urlCacheKey, 'Unknown size', cacheSubDir); + return 'Unknown size'; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + logger.error(`[XPRIME] Could not fetch size for ${url.substring(0, 50)}...`, error); + await this.saveToCache(urlCacheKey, 'Unknown size', cacheSubDir); + return 'Unknown size'; + } + } + + private async fetchWithRetry(url: string, options: any, maxRetries: number = MAX_RETRIES_XPRIME) { + let lastError; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetchImpl(url, options); + if (!response.ok) { + let errorBody = ''; + try { + errorBody = await response.text(); + } catch (e) { + // ignore + } + + const httpError = new Error(`HTTP error! Status: ${response.status} ${response.statusText}. Body: ${errorBody.substring(0, 200)}`); + (httpError as any).status = response.status; + throw httpError; + } + return response; + } catch (error: any) { + lastError = error; + logger.error(`[XPRIME] Fetch attempt ${attempt}/${maxRetries} failed for ${url}:`, error); + + // If it's a 403 error, stop retrying immediately + if (error.status === 403) { + logger.log(`[XPRIME] Encountered 403 Forbidden for ${url}. Halting retries.`); + throw lastError; + } + + if (attempt < maxRetries) { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS_XPRIME * Math.pow(2, attempt - 1))); + } + } + } + + logger.error(`[XPRIME] All fetch attempts failed for ${url}. Last error:`, lastError); + if (lastError) throw lastError; + else throw new Error(`[XPRIME] All fetch attempts failed for ${url} without a specific error captured.`); + } + + async getStreams(mediaId: string, mediaType: string, season?: number, episode?: number): Promise { + try { + logger.log(`[XPRIME] Getting streams for ${mediaType} with ID: ${mediaId}`); + + // First check if internal providers are enabled + const settingsJson = await AsyncStorage.getItem('app_settings'); + if (settingsJson) { + const settings = JSON.parse(settingsJson); + if (settings.enableInternalProviders === false) { + logger.log('[XPRIME] Internal providers are disabled in settings, skipping Xprime.tv'); + return []; + } + } + + // Check individual XPRIME provider setting + const xprimeSettingsJson = await AsyncStorage.getItem('xprime_settings'); + if (xprimeSettingsJson) { + const xprimeSettings = JSON.parse(xprimeSettingsJson); + if (xprimeSettings.enabled === false) { + logger.log('[XPRIME] XPRIME provider is disabled in settings, skipping Xprime.tv'); + return []; + } + } + + // Extract the actual title from TMDB if this is an ID + let title = mediaId; + let year: number | undefined = undefined; + + if (mediaId.startsWith('tt') || mediaId.startsWith('tmdb:')) { + let tmdbId: number | null = null; + + // Handle IMDB IDs + if (mediaId.startsWith('tt')) { + logger.log(`[XPRIME] Converting IMDB ID to TMDB ID: ${mediaId}`); + tmdbId = await tmdbService.findTMDBIdByIMDB(mediaId); + } + // Handle TMDB IDs + else if (mediaId.startsWith('tmdb:')) { + tmdbId = parseInt(mediaId.split(':')[1], 10); + } + + if (tmdbId) { + // Fetch metadata from TMDB API + if (mediaType === 'movie') { + logger.log(`[XPRIME] Fetching movie details from TMDB for ID: ${tmdbId}`); + const movieDetails = await tmdbService.getMovieDetails(tmdbId.toString()); + if (movieDetails) { + title = movieDetails.title; + year = movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4), 10) : undefined; + logger.log(`[XPRIME] Using movie title "${title}" (${year}) for search`); + } + } else { + logger.log(`[XPRIME] Fetching TV show details from TMDB for ID: ${tmdbId}`); + const showDetails = await tmdbService.getTVShowDetails(tmdbId); + if (showDetails) { + title = showDetails.name; + year = showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4), 10) : undefined; + logger.log(`[XPRIME] Using TV show title "${title}" (${year}) for search`); + } + } + } + } + + if (!title || !year) { + logger.log('[XPRIME] Skipping fetch: title or year is missing.'); + return []; + } + + const rawXprimeStreams = await this.getXprimeStreams(title, year, mediaType, season, episode); + + // Convert to Stream format + const streams: Stream[] = rawXprimeStreams.map(xprimeStream => ({ + name: `XPRIME ${xprimeStream.quality.toUpperCase()}`, + title: xprimeStream.size !== 'Unknown size' ? xprimeStream.size : '', + url: xprimeStream.url, + behaviorHints: { + notWebReady: false + } + })); + + logger.log(`[XPRIME] Found ${streams.length} streams`); + return streams; + } catch (error) { + logger.error(`[XPRIME] Error getting streams:`, error); + return []; + } + } + + private async getXprimeStreams(title: string, year: number, type: string, seasonNum?: number, episodeNum?: number): Promise { + let rawXprimeStreams: XprimeStream[] = []; + + try { + logger.log(`[XPRIME] Fetch attempt for '${title}' (${year}). Type: ${type}, S: ${seasonNum}, E: ${episodeNum}`); + + const xprimeName = encodeURIComponent(title); + let xprimeApiUrl: string; + + // type here is tmdbTypeFromId which is 'movie' or 'tv'/'series' + if (type === 'movie') { + xprimeApiUrl = `https://backend.xprime.tv/primebox?name=${xprimeName}&year=${year}&fallback_year=${year}`; + } else if (type === 'tv' || type === 'series') { // Accept both 'tv' and 'series' for compatibility + if (seasonNum !== null && seasonNum !== undefined && episodeNum !== null && episodeNum !== undefined) { + xprimeApiUrl = `https://backend.xprime.tv/primebox?name=${xprimeName}&year=${year}&fallback_year=${year}&season=${seasonNum}&episode=${episodeNum}`; + } else { + logger.log('[XPRIME] Skipping series request: missing season/episode numbers.'); + return []; + } + } else { + logger.log(`[XPRIME] Skipping request: unknown type '${type}'.`); + return []; + } + + let xprimeResult: any; + + // Direct fetch only + logger.log(`[XPRIME] Fetching directly: ${xprimeApiUrl}`); + const xprimeResponse = await this.fetchWithRetry(xprimeApiUrl, { + headers: { + ...BROWSER_HEADERS_XPRIME, + 'Origin': 'https://pstream.org', + 'Referer': 'https://pstream.org/', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'cross-site', + 'Sec-Fetch-Dest': 'empty' + } + }); + xprimeResult = await xprimeResponse.json(); + + // Process the result + this.processXprimeResult(xprimeResult, rawXprimeStreams, title, type, seasonNum, episodeNum); + + // Fetch stream sizes concurrently for all Xprime streams + if (rawXprimeStreams.length > 0) { + logger.log('[XPRIME] Fetching stream sizes...'); + const sizePromises = rawXprimeStreams.map(async (stream) => { + stream.size = await this.fetchStreamSize(stream.url); + return stream; + }); + await Promise.all(sizePromises); + logger.log(`[XPRIME] Found ${rawXprimeStreams.length} streams with sizes.`); + } + + return rawXprimeStreams; + + } catch (xprimeError) { + logger.error('[XPRIME] Error fetching or processing streams:', xprimeError); + return []; + } + } + + // Helper function to process Xprime API response + private processXprimeResult(xprimeResult: any, rawXprimeStreams: XprimeStream[], title: string, type: string, seasonNum?: number, episodeNum?: number) { + const processXprimeItem = (item: any) => { + if (item && typeof item === 'object' && !item.error && item.streams && typeof item.streams === 'object') { + Object.entries(item.streams).forEach(([quality, fileUrl]) => { + if (fileUrl && typeof fileUrl === 'string') { + rawXprimeStreams.push({ + url: fileUrl, + quality: quality || 'Unknown', + title: `${title} - ${(type === 'tv' || type === 'series') ? `S${String(seasonNum).padStart(2,'0')}E${String(episodeNum).padStart(2,'0')} ` : ''}${quality}`, + provider: 'XPRIME', + codecs: [], + size: 'Unknown size' + }); + } + }); + } else { + logger.log('[XPRIME] Skipping item due to missing/invalid streams or an error was reported by Xprime API:', item && item.error); + } + }; + + if (Array.isArray(xprimeResult)) { + xprimeResult.forEach(processXprimeItem); + } else if (xprimeResult) { + processXprimeItem(xprimeResult); + } else { + logger.log('[XPRIME] No result from Xprime API to process.'); + } + } +} + +export const xprimeService = new XprimeService(); \ No newline at end of file