mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
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:
parent
9e19628b46
commit
046c9e3f97
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