Ios #14

Merged
tapframe merged 88 commits from ios into main 2025-06-20 13:54:29 +00:00
10 changed files with 1235 additions and 229 deletions
Showing only changes of commit 046c9e3f97 - Show all commits

View file

@ -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<AudioTrackModalProps> = ({
<Animated.View
style={[
{
width: Math.min(width - 32, 520),
maxHeight: height * 0.85,
width: MODAL_WIDTH,
maxHeight: MODAL_MAX_HEIGHT,
minHeight: height * 0.3,
overflow: 'hidden',
elevation: 25,
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.4,
shadowRadius: 25,
alignSelf: 'center',
},
modalStyle,
]}
@ -172,6 +178,8 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
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<AudioTrackModalProps> = ({
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
width: '100%',
}}
>
<Animated.View
@ -242,215 +251,156 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
{/* Content */}
<ScrollView
style={{
maxHeight: height * 0.6,
maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height
backgroundColor: 'transparent',
width: '100%',
}}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
padding: 24,
paddingBottom: 32,
width: '100%',
}}
bounces={false}
>
{/* Audio Tracks Section */}
<Animated.View
entering={FadeInDown.duration(400).delay(150)}
layout={Layout.springify()}
style={{
marginBottom: 16,
}}
>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 20,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
}}>
<LinearGradient
colors={['#F97316', '#EA580C']}
style={{
width: 12,
height: 12,
borderRadius: 6,
marginRight: 16,
elevation: 3,
shadowColor: '#F97316',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 4,
<View style={styles.modernTrackListContainer}>
{vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => (
<Animated.View
key={track.id}
entering={FadeInDown.duration(300).delay(150 + (index * 50))}
layout={Layout.springify()}
style={{
marginBottom: 16,
width: '100%',
}}
/>
<View style={{ flex: 1 }}>
<Text style={{
color: '#fff',
fontSize: 18,
fontWeight: '700',
letterSpacing: -0.3,
}}>
Available Audio Tracks
</Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 12,
marginTop: 1,
fontWeight: '500',
}}>
Select your preferred audio language
</Text>
</View>
</View>
{/* Audio Tracks List */}
{vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => {
const isSelected = selectedAudioTrack === track.id;
return (
<Animated.View
key={track.id}
entering={FadeInDown.duration(300).delay(200 + (index * 50))}
layout={Layout.springify()}
style={{ marginBottom: 16 }}
>
<TouchableOpacity
style={{
backgroundColor: selectedAudioTrack === track.id
? 'rgba(249, 115, 22, 0.08)'
: 'rgba(255, 255, 255, 0.03)',
borderRadius: 20,
padding: 20,
borderWidth: 2,
borderColor: selectedAudioTrack === track.id
? 'rgba(249, 115, 22, 0.4)'
: 'rgba(255, 255, 255, 0.08)',
elevation: selectedAudioTrack === track.id ? 8 : 3,
shadowColor: selectedAudioTrack === track.id ? '#F97316' : '#000',
shadowOffset: { width: 0, height: selectedAudioTrack === track.id ? 4 : 2 },
shadowOpacity: selectedAudioTrack === track.id ? 0.3 : 0.1,
shadowRadius: selectedAudioTrack === track.id ? 12 : 6,
width: '100%',
}}
onPress={() => {
selectAudioTrack(track.id);
handleClose();
}}
activeOpacity={0.85}
>
<TouchableOpacity
style={{
backgroundColor: isSelected
? 'rgba(249, 115, 22, 0.08)'
: 'rgba(255, 255, 255, 0.03)',
borderRadius: 20,
padding: 20,
borderWidth: 2,
borderColor: isSelected
? 'rgba(249, 115, 22, 0.4)'
: 'rgba(255, 255, 255, 0.08)',
elevation: isSelected ? 8 : 3,
shadowColor: isSelected ? '#F97316' : '#000',
shadowOffset: { width: 0, height: isSelected ? 4 : 2 },
shadowOpacity: isSelected ? 0.3 : 0.1,
shadowRadius: isSelected ? 12 : 6,
}}
onPress={() => {
selectAudioTrack(track.id);
handleClose();
}}
activeOpacity={0.85}
>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<View style={{ flex: 1, marginRight: 16 }}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
gap: 12,
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}}>
<View style={{ flex: 1, marginRight: 16 }}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
gap: 12,
}}>
<Text style={{
color: selectedAudioTrack === track.id ? '#fff' : 'rgba(255, 255, 255, 0.95)',
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.2,
flex: 1,
}}>
<Text style={{
color: isSelected ? '#fff' : 'rgba(255, 255, 255, 0.95)',
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.2,
flex: 1,
}}>
{getTrackDisplayName(track)}
</Text>
{isSelected && (
<Animated.View
entering={BounceIn.duration(300)}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(249, 115, 22, 0.25)',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(249, 115, 22, 0.5)',
}}
>
<MaterialIcons name="volume-up" size={12} color="#F97316" />
<Text style={{
color: '#F97316',
fontSize: 10,
fontWeight: '800',
marginLeft: 3,
letterSpacing: 0.3,
}}>
ACTIVE
</Text>
</Animated.View>
)}
</View>
{getTrackDisplayName(track)}
</Text>
{(track.name && track.language) && (
<Text style={{
color: 'rgba(255, 255, 255, 0.65)',
fontSize: 13,
marginBottom: 8,
lineHeight: 18,
fontWeight: '400',
}}>
{track.name}
</Text>
{selectedAudioTrack === track.id && (
<Animated.View
entering={BounceIn.duration(300)}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(249, 115, 22, 0.25)',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(249, 115, 22, 0.5)',
}}
>
<MaterialIcons name="volume-up" size={12} color="#F97316" />
<Text style={{
color: '#F97316',
fontSize: 10,
fontWeight: '800',
marginLeft: 3,
letterSpacing: 0.3,
}}>
ACTIVE
</Text>
</Animated.View>
)}
<View style={{
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
alignItems: 'center',
}}>
<AudioBadge
text="AUDIO TRACK"
color="#F97316"
bgColor="rgba(249, 115, 22, 0.15)"
icon="audiotrack"
/>
{track.language && (
<AudioBadge
text={track.language.toUpperCase()}
color="#6B7280"
bgColor="rgba(107, 114, 128, 0.15)"
icon="language"
delay={50}
/>
)}
</View>
</View>
<View style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: isSelected
? 'rgba(249, 115, 22, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
justifyContent: 'center',
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
alignItems: 'center',
borderWidth: 2,
borderColor: isSelected
? 'rgba(249, 115, 22, 0.3)'
: 'rgba(255, 255, 255, 0.1)',
}}>
{isSelected ? (
<Animated.View entering={ZoomIn.duration(200)}>
<MaterialIcons name="check-circle" size={24} color="#F97316" />
</Animated.View>
) : (
<MaterialIcons name="volume-up" size={24} color="rgba(255,255,255,0.6)" />
<AudioBadge
text="AUDIO TRACK"
color="#F97316"
bgColor="rgba(249, 115, 22, 0.15)"
icon="audiotrack"
/>
{track.language && (
<AudioBadge
text={track.language.toUpperCase()}
color="#6B7280"
bgColor="rgba(107, 114, 128, 0.15)"
icon="language"
delay={50}
/>
)}
</View>
</View>
</TouchableOpacity>
</Animated.View>
);
}) : (
<View style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: selectedAudioTrack === track.id
? 'rgba(249, 115, 22, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: selectedAudioTrack === track.id
? 'rgba(249, 115, 22, 0.3)'
: 'rgba(255, 255, 255, 0.1)',
}}>
{selectedAudioTrack === track.id ? (
<Animated.View entering={ZoomIn.duration(200)}>
<MaterialIcons name="check-circle" size={24} color="#F97316" />
</Animated.View>
) : (
<MaterialIcons name="volume-up" size={24} color="rgba(255,255,255,0.6)" />
)}
</View>
</View>
</TouchableOpacity>
</Animated.View>
)) : (
<Animated.View
entering={FadeInDown.duration(300).delay(200)}
entering={FadeInDown.duration(300).delay(150)}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderRadius: 20,
@ -458,6 +408,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.05)',
width: '100%',
}}
>
<MaterialIcons name="volume-off" size={48} color="rgba(255, 255, 255, 0.3)" />
@ -469,7 +420,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
textAlign: 'center',
letterSpacing: -0.3,
}}>
No audio tracks available
No audio tracks found
</Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.4)',
@ -478,11 +429,11 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
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.
</Text>
</Animated.View>
)}
</Animated.View>
</View>
</ScrollView>
</BlurView>
</Animated.View>

View file

@ -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<SourcesModalProps> = ({
<Animated.View
style={[
{
width: Math.min(width - 32, 520),
maxHeight: height * 0.85,
width: MODAL_WIDTH,
maxHeight: MODAL_MAX_HEIGHT,
minHeight: height * 0.3,
overflow: 'hidden',
elevation: 25,
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.4,
shadowRadius: 25,
alignSelf: 'center',
},
modalStyle,
]}
@ -249,6 +255,8 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
borderRadius: 28,
overflow: 'hidden',
backgroundColor: 'rgba(26, 26, 26, 0.8)',
width: '100%',
height: '100%',
}}
>
{/* Header */}
@ -267,6 +275,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
width: '100%',
}}
>
<Animated.View
@ -319,13 +328,15 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
{/* Content */}
<ScrollView
style={{
maxHeight: height * 0.6,
maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height
backgroundColor: 'transparent',
width: '100%',
}}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
padding: 24,
paddingBottom: 32,
width: '100%',
}}
bounces={false}
>
@ -336,6 +347,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
layout={Layout.springify()}
style={{
marginBottom: streams.length > 0 ? 32 : 0,
width: '100%',
}}
>
{/* Provider Header */}
@ -346,6 +358,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
width: '100%',
}}>
<LinearGradient
colors={providerId === 'hdrezka' ? ['#00d4aa', '#00a085'] : ['#E50914', '#B00610']}
@ -400,7 +413,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
</View>
{/* Streams Grid */}
<View style={{ gap: 16 }}>
<View style={{ gap: 16, width: '100%' }}>
{streams.map((stream, index) => {
const quality = getQualityFromTitle(stream.title);
const isSelected = isStreamSelected(stream);
@ -415,6 +428,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
key={`${stream.url}-${index}`}
entering={FadeInDown.duration(300).delay((providerIndex * 80) + (index * 40))}
layout={Layout.springify()}
style={{ width: '100%' }}
>
<TouchableOpacity
style={{
@ -433,6 +447,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
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<SourcesModalProps> = ({
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
width: '100%',
}}>
{/* Stream Info */}
<View style={{ flex: 1, marginRight: 16 }}>

View file

@ -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<SubtitleModalsProps> = ({
<Animated.View
style={[
{
width: Math.min(width - 32, 520),
maxHeight: height * 0.85,
width: MODAL_WIDTH,
maxHeight: MODAL_MAX_HEIGHT,
minHeight: height * 0.3,
overflow: 'hidden',
elevation: 25,
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.4,
shadowRadius: 25,
alignSelf: 'center',
},
modalStyle,
]}
@ -226,6 +232,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
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<SubtitleModalsProps> = ({
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
width: '100%',
}}
>
<Animated.View
@ -296,13 +305,15 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{/* Content */}
<ScrollView
style={{
maxHeight: height * 0.6,
maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height
backgroundColor: 'transparent',
width: '100%',
}}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
padding: 24,
paddingBottom: 32,
width: '100%',
}}
bounces={false}
>
@ -890,14 +901,16 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<Animated.View
style={[
{
width: Math.min(width - 32, 520),
maxHeight: height * 0.85,
width: MODAL_WIDTH,
maxHeight: MODAL_MAX_HEIGHT,
minHeight: height * 0.3,
overflow: 'hidden',
elevation: 25,
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.4,
shadowRadius: 25,
alignSelf: 'center',
},
languageModalStyle,
]}
@ -910,6 +923,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
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<SubtitleModalsProps> = ({
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
width: '100%',
}}
>
<Animated.View
@ -980,13 +996,15 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{/* Content */}
<ScrollView
style={{
maxHeight: height * 0.6,
maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height
backgroundColor: 'transparent',
width: '100%',
}}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
padding: 24,
paddingBottom: 32,
width: '100%',
}}
bounces={false}
>

View file

@ -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<any>, 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<any>[] = [hdrezkaPromise];
// Include Xprime and HDRezka in fetchPromises array (Xprime first)
const fetchPromises: Promise<any>[] = [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<any>[] = [hdrezkaPromise];
// Add HDRezka source for episodes
const hdrezkaEpisodePromise = processExternalSource('hdrezka',
processHDRezkaSource('series', id, parseInt(season), parseInt(episode), true),
true
);
const fetchPromises: Promise<any>[] = [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}`);

View file

@ -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<RootStackParamList>;
@ -1012,6 +1014,21 @@ const AppNavigator = () => {
},
}}
/>
<Stack.Screen
name="InternalProvidersSettings"
component={InternalProvidersSettings}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</View>
</PaperProvider>

View file

@ -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<SettingItemProps> = ({
title,
description,
icon,
value,
onValueChange,
isLast,
badge,
}) => {
const { currentTheme } = useTheme();
return (
<View
style={[
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: 'rgba(255,255,255,0.08)' },
]}
>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons
name={icon}
size={20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingText}>
<View style={styles.titleRow}>
<Text
style={[
styles.settingTitle,
{ color: currentTheme.colors.text },
]}
>
{title}
</Text>
{badge && (
<View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
)}
</View>
{description && (
<Text
style={[
styles.settingDescription,
{ color: currentTheme.colors.textMuted },
]}
>
{description}
</Text>
)}
</View>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (value ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
</View>
);
};
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 (
<SafeAreaView
style={[
styles.container,
{ backgroundColor: currentTheme.colors.darkBackground },
]}
>
<StatusBar
translucent
backgroundColor="transparent"
barStyle="light-content"
/>
<View style={styles.header}>
<TouchableOpacity
onPress={handleBack}
style={styles.backButton}
activeOpacity={0.7}
>
<MaterialIcons
name="arrow-back"
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
<Text
style={[
styles.headerTitle,
{ color: currentTheme.colors.text },
]}
>
Internal Providers
</Text>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{/* Master Toggle Section */}
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: currentTheme.colors.textMuted },
]}
>
MASTER CONTROL
</Text>
<View
style={[
styles.card,
{ backgroundColor: currentTheme.colors.elevation2 },
]}
>
<SettingItem
title="Enable Internal Providers"
description="Toggle all built-in streaming providers on/off"
icon="toggle-on"
value={settings.enableInternalProviders}
onValueChange={handleMasterToggle}
isLast={true}
/>
</View>
</View>
{/* Individual Providers Section */}
{settings.enableInternalProviders && (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: currentTheme.colors.textMuted },
]}
>
INDIVIDUAL PROVIDERS
</Text>
<View
style={[
styles.card,
{ backgroundColor: currentTheme.colors.elevation2 },
]}
>
<SettingItem
title="XPRIME"
description="High-quality streams with various resolutions"
icon="star"
value={xprimeEnabled}
onValueChange={handleXprimeToggle}
badge="NEW"
/>
<SettingItem
title="HDRezka"
description="Popular streaming service with multiple quality options"
icon="hd"
value={hdrezkaEnabled}
onValueChange={handleHdrezkaToggle}
isLast={true}
/>
</View>
</View>
)}
{/* Information Section */}
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: currentTheme.colors.textMuted },
]}
>
INFORMATION
</Text>
<View
style={[
styles.infoCard,
{
backgroundColor: currentTheme.colors.elevation2,
borderColor: `${currentTheme.colors.primary}30`
},
]}
>
<MaterialIcons
name="info-outline"
size={24}
color={currentTheme.colors.primary}
style={styles.infoIcon}
/>
<View style={styles.infoContent}>
<Text
style={[
styles.infoTitle,
{ color: currentTheme.colors.text },
]}
>
About Internal Providers
</Text>
<Text
style={[
styles.infoDescription,
{ color: currentTheme.colors.textMuted },
]}
>
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.
</Text>
<View style={styles.featureList}>
<View style={styles.featureItem}>
<MaterialIcons
name="check-circle"
size={16}
color={currentTheme.colors.primary}
/>
<Text
style={[
styles.featureText,
{ color: currentTheme.colors.textMuted },
]}
>
No addon installation required
</Text>
</View>
<View style={styles.featureItem}>
<MaterialIcons
name="check-circle"
size={16}
color={currentTheme.colors.primary}
/>
<Text
style={[
styles.featureText,
{ color: currentTheme.colors.textMuted },
]}
>
Multiple quality options
</Text>
</View>
<View style={styles.featureItem}>
<MaterialIcons
name="check-circle"
size={16}
color={currentTheme.colors.primary}
/>
<Text
style={[
styles.featureText,
{ color: currentTheme.colors.textMuted },
]}
>
Fast and reliable streaming
</Text>
</View>
</View>
</View>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
paddingBottom: 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;

View file

@ -410,12 +410,8 @@ const SettingsScreen: React.FC = () => {
title="Internal Providers"
description="Enable or disable built-in providers like HDRezka"
icon="source"
renderControl={() => (
<CustomSwitch
value={settings.enableInternalProviders}
onValueChange={(value) => updateSetting('enableInternalProviders', value)}
/>
)}
renderControl={ChevronRight}
onPress={() => navigation.navigate('InternalProvidersSettings')}
/>
<SettingItem
title="Home Screen"

View file

@ -749,11 +749,15 @@ export const StreamsScreen = () => {
{ 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,

View file

@ -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;

View file

@ -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<any> {
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<string> {
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<Stream[]> {
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<XprimeStream[]> {
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();