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.
This commit is contained in:
tapframe 2025-06-11 02:10:10 +05:30
parent 9e19628b46
commit 046c9e3f97
10 changed files with 1235 additions and 229 deletions

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();