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