Ios #14
10 changed files with 1235 additions and 229 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
515
src/screens/InternalProvidersSettings.tsx
Normal file
515
src/screens/InternalProvidersSettings.tsx
Normal 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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
380
src/services/xprimeService.ts
Normal file
380
src/services/xprimeService.ts
Normal 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();
|
||||
Loading…
Reference in a new issue