mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
Revert "Refactor StreamsScreen to streamline component structure and enhance readability; replace inline components with imports for MovieHero and EpisodeHero, and utilize custom hooks for provider management. Optimize loading logic and animation effects, while removing unused code and improving overall performance."
This reverts commit 3b6fb438e3.
This commit is contained in:
parent
3b6fb438e3
commit
dfda3ff38a
7 changed files with 906 additions and 1034 deletions
|
|
@ -1,223 +0,0 @@
|
|||
import React from 'react';
|
||||
import { StyleSheet, View, Text, ImageBackground } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
import { colors } from '../../styles/colors';
|
||||
import { tmdbService } from '../../services/tmdbService';
|
||||
|
||||
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
||||
|
||||
interface EpisodeHeroProps {
|
||||
currentEpisode: {
|
||||
name: string;
|
||||
overview?: string;
|
||||
still_path?: string;
|
||||
air_date?: string | null;
|
||||
vote_average?: number;
|
||||
runtime?: number;
|
||||
episodeString: string;
|
||||
season_number?: number;
|
||||
episode_number?: number;
|
||||
} | null;
|
||||
metadata: {
|
||||
poster?: string;
|
||||
} | null;
|
||||
animatedStyle: any;
|
||||
}
|
||||
|
||||
const EpisodeHero = ({ currentEpisode, metadata, animatedStyle }: EpisodeHeroProps) => {
|
||||
if (!currentEpisode) return null;
|
||||
|
||||
const episodeImage = currentEpisode.still_path
|
||||
? tmdbService.getImageUrl(currentEpisode.still_path, 'original')
|
||||
: metadata?.poster || null;
|
||||
|
||||
// Format air date safely
|
||||
const formattedAirDate = currentEpisode.air_date !== undefined
|
||||
? tmdbService.formatAirDate(currentEpisode.air_date)
|
||||
: 'Unknown';
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.streamsHeroContainer, animatedStyle]}>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(600).springify()}
|
||||
style={StyleSheet.absoluteFill}
|
||||
>
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(800).delay(100).springify().withInitialValues({
|
||||
transform: [{ scale: 1.05 }]
|
||||
})}
|
||||
style={StyleSheet.absoluteFill}
|
||||
>
|
||||
<ImageBackground
|
||||
source={episodeImage ? { uri: episodeImage } : undefined}
|
||||
style={styles.streamsHeroBackground}
|
||||
fadeDuration={0}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0)',
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.7)',
|
||||
'rgba(0,0,0,0.85)',
|
||||
'rgba(0,0,0,0.95)',
|
||||
colors.darkBackground
|
||||
]}
|
||||
locations={[0, 0.3, 0.5, 0.7, 0.85, 1]}
|
||||
style={styles.streamsHeroGradient}
|
||||
>
|
||||
<View style={styles.streamsHeroContent}>
|
||||
<View style={styles.streamsHeroInfo}>
|
||||
<Text style={styles.streamsHeroEpisodeNumber}>
|
||||
{currentEpisode.episodeString}
|
||||
</Text>
|
||||
<Text style={styles.streamsHeroTitle} numberOfLines={1}>
|
||||
{currentEpisode.name}
|
||||
</Text>
|
||||
{currentEpisode.overview && (
|
||||
<Text style={styles.streamsHeroOverview} numberOfLines={2}>
|
||||
{currentEpisode.overview}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.streamsHeroMeta}>
|
||||
<Text style={styles.streamsHeroReleased}>
|
||||
{formattedAirDate}
|
||||
</Text>
|
||||
{currentEpisode.vote_average && currentEpisode.vote_average > 0 && (
|
||||
<View style={styles.streamsHeroRating}>
|
||||
<Image
|
||||
source={{ uri: TMDB_LOGO }}
|
||||
style={styles.tmdbLogo}
|
||||
contentFit="contain"
|
||||
/>
|
||||
<Text style={styles.streamsHeroRatingText}>
|
||||
{currentEpisode.vote_average.toFixed(1)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{currentEpisode.runtime && (
|
||||
<View style={styles.streamsHeroRuntime}>
|
||||
<MaterialIcons name="schedule" size={16} color={colors.mediumEmphasis} />
|
||||
<Text style={styles.streamsHeroRuntimeText}>
|
||||
{currentEpisode.runtime >= 60
|
||||
? `${Math.floor(currentEpisode.runtime / 60)}h ${currentEpisode.runtime % 60}m`
|
||||
: `${currentEpisode.runtime}m`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</ImageBackground>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
streamsHeroContainer: {
|
||||
width: '100%',
|
||||
height: 300,
|
||||
marginBottom: 0,
|
||||
position: 'relative',
|
||||
backgroundColor: colors.black,
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
streamsHeroBackground: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: colors.black,
|
||||
},
|
||||
streamsHeroGradient: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
padding: 16,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
streamsHeroContent: {
|
||||
width: '100%',
|
||||
},
|
||||
streamsHeroInfo: {
|
||||
width: '100%',
|
||||
},
|
||||
streamsHeroEpisodeNumber: {
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 2,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
streamsHeroTitle: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
},
|
||||
streamsHeroOverview: {
|
||||
color: colors.mediumEmphasis,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 2,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
streamsHeroMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginTop: 0,
|
||||
},
|
||||
streamsHeroReleased: {
|
||||
color: colors.mediumEmphasis,
|
||||
fontSize: 14,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
streamsHeroRating: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 4,
|
||||
marginTop: 0,
|
||||
},
|
||||
tmdbLogo: {
|
||||
width: 20,
|
||||
height: 14,
|
||||
},
|
||||
streamsHeroRatingText: {
|
||||
color: '#01b4e4',
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
marginLeft: 4,
|
||||
},
|
||||
streamsHeroRuntime: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 6,
|
||||
},
|
||||
streamsHeroRuntimeText: {
|
||||
color: colors.mediumEmphasis,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(EpisodeHero);
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import React from 'react';
|
||||
import { StyleSheet, Text, View, ImageBackground, Dimensions, Platform } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Image } from 'expo-image';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import { colors } from '../../styles/colors';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
interface MovieHeroProps {
|
||||
metadata: {
|
||||
name: string;
|
||||
logo?: string;
|
||||
banner?: string;
|
||||
poster?: string;
|
||||
} | null;
|
||||
animatedStyle: any;
|
||||
}
|
||||
|
||||
const MovieHero = ({ metadata, animatedStyle }: MovieHeroProps) => {
|
||||
if (!metadata) return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.movieTitleContainer, animatedStyle]}>
|
||||
<ImageBackground
|
||||
source={{ uri: metadata.banner || metadata.poster }}
|
||||
style={styles.movieTitleBackground}
|
||||
resizeMode="cover"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0.4)',
|
||||
'rgba(0,0,0,0.6)',
|
||||
'rgba(0,0,0,0.8)',
|
||||
colors.darkBackground
|
||||
]}
|
||||
locations={[0, 0.3, 0.7, 1]}
|
||||
style={styles.movieTitleGradient}
|
||||
>
|
||||
<View style={styles.movieTitleContent}>
|
||||
{metadata.logo ? (
|
||||
<Image
|
||||
source={{ uri: metadata.logo }}
|
||||
style={styles.movieLogo}
|
||||
contentFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.movieTitle} numberOfLines={2}>
|
||||
{metadata.name}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</ImageBackground>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
movieTitleContainer: {
|
||||
width: '100%',
|
||||
height: 180,
|
||||
backgroundColor: colors.black,
|
||||
pointerEvents: 'box-none',
|
||||
},
|
||||
movieTitleBackground: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: colors.black,
|
||||
},
|
||||
movieTitleGradient: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
movieTitleContent: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
marginTop: Platform.OS === 'android' ? 35 : 45,
|
||||
},
|
||||
movieLogo: {
|
||||
width: width * 0.6,
|
||||
height: 70,
|
||||
marginBottom: 8,
|
||||
},
|
||||
movieTitle: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 28,
|
||||
fontWeight: '900',
|
||||
textAlign: 'center',
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(MovieHero);
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { FlatList, StyleSheet, Text, TouchableOpacity } from 'react-native';
|
||||
import { colors } from '../../styles/colors';
|
||||
|
||||
interface ProviderFilterProps {
|
||||
selectedProvider: string;
|
||||
providers: Array<{ id: string; name: string; }>;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const ProviderFilter = ({ selectedProvider, providers, onSelect }: ProviderFilterProps) => {
|
||||
const renderItem = useCallback(({ item }: { item: { id: string; name: string } }) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={[
|
||||
styles.filterChip,
|
||||
selectedProvider === item.id && styles.filterChipSelected
|
||||
]}
|
||||
onPress={() => onSelect(item.id)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.filterChipText,
|
||||
selectedProvider === item.id && styles.filterChipTextSelected
|
||||
]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
), [selectedProvider, onSelect]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={providers}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={item => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.filterScroll}
|
||||
bounces={true}
|
||||
overScrollMode="never"
|
||||
decelerationRate="fast"
|
||||
initialNumToRender={5}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={3}
|
||||
getItemLayout={(data, index) => ({
|
||||
length: 100, // Approximate width of each item
|
||||
offset: 100 * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
filterScroll: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
filterChip: {
|
||||
backgroundColor: colors.transparentLight,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
marginRight: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.transparent,
|
||||
},
|
||||
filterChipSelected: {
|
||||
backgroundColor: colors.transparentLight,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
filterChipText: {
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
filterChipTextSelected: {
|
||||
color: colors.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(ProviderFilter);
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { colors } from '../../styles/colors';
|
||||
import { Stream } from '../../types/metadata';
|
||||
import QualityBadge from '../metadata/QualityBadge';
|
||||
|
||||
interface StreamCardProps {
|
||||
stream: Stream;
|
||||
onPress: () => void;
|
||||
index: number;
|
||||
isLoading?: boolean;
|
||||
statusMessage?: string;
|
||||
}
|
||||
|
||||
const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: StreamCardProps) => {
|
||||
const quality = stream.title?.match(/(\d+)p/)?.[1] || null;
|
||||
const isHDR = stream.title?.toLowerCase().includes('hdr');
|
||||
const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV');
|
||||
const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
|
||||
const isDebrid = stream.behaviorHints?.cached;
|
||||
|
||||
const displayTitle = stream.name || stream.title || 'Unnamed Stream';
|
||||
const displayAddonName = stream.title || '';
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.streamCard,
|
||||
isLoading && styles.streamCardLoading
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.streamDetails}>
|
||||
<View style={styles.streamNameRow}>
|
||||
<View style={styles.streamTitleContainer}>
|
||||
<Text style={styles.streamName}>
|
||||
{displayTitle}
|
||||
</Text>
|
||||
{displayAddonName && displayAddonName !== displayTitle && (
|
||||
<Text style={styles.streamAddonName}>
|
||||
{displayAddonName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Show loading indicator if stream is loading */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingIndicator}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>
|
||||
{statusMessage || "Loading..."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.streamMetaRow}>
|
||||
{quality && quality >= "720" && (
|
||||
<QualityBadge type="HD" />
|
||||
)}
|
||||
|
||||
{isDolby && (
|
||||
<QualityBadge type="VISION" />
|
||||
)}
|
||||
|
||||
{size && (
|
||||
<View style={[styles.chip, { backgroundColor: colors.darkGray }]}>
|
||||
<Text style={styles.chipText}>{size}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isDebrid && (
|
||||
<View style={[styles.chip, { backgroundColor: colors.success }]}>
|
||||
<Text style={styles.chipText}>DEBRID</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.streamAction}>
|
||||
<MaterialIcons
|
||||
name="play-arrow"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
streamCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
minHeight: 70,
|
||||
backgroundColor: colors.elevation1,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.05)',
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
},
|
||||
streamCardLoading: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
streamDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
streamNameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8
|
||||
},
|
||||
streamTitleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
streamName: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
lineHeight: 20,
|
||||
color: colors.highEmphasis,
|
||||
},
|
||||
streamAddonName: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
color: colors.mediumEmphasis,
|
||||
marginBottom: 6,
|
||||
},
|
||||
streamMetaRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
marginBottom: 6,
|
||||
alignItems: 'center',
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
chipText: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
loadingText: {
|
||||
color: colors.primary,
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
streamAction: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.elevation2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(StreamCard);
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
import { Platform, Linking } from 'react-native';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
import { Stream } from '../types/metadata';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface UseStreamNavigationProps {
|
||||
metadata: {
|
||||
name?: string;
|
||||
year?: number;
|
||||
} | null;
|
||||
currentEpisode?: {
|
||||
name?: string;
|
||||
season_number?: number;
|
||||
episode_number?: number;
|
||||
} | null;
|
||||
id: string;
|
||||
type: string;
|
||||
selectedEpisode?: string;
|
||||
useExternalPlayer?: boolean;
|
||||
preferredPlayer?: 'internal' | 'vlc' | 'outplayer' | 'infuse' | 'vidhub' | 'external';
|
||||
}
|
||||
|
||||
export const useStreamNavigation = ({
|
||||
metadata,
|
||||
currentEpisode,
|
||||
id,
|
||||
type,
|
||||
selectedEpisode,
|
||||
useExternalPlayer,
|
||||
preferredPlayer
|
||||
}: UseStreamNavigationProps) => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
||||
const navigateToPlayer = useCallback((stream: Stream) => {
|
||||
navigation.navigate('Player', {
|
||||
uri: stream.url,
|
||||
title: metadata?.name || '',
|
||||
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
|
||||
season: type === 'series' ? currentEpisode?.season_number : undefined,
|
||||
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
|
||||
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
|
||||
year: metadata?.year,
|
||||
streamProvider: stream.name,
|
||||
id,
|
||||
type,
|
||||
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
|
||||
});
|
||||
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode]);
|
||||
|
||||
const handleStreamPress = useCallback(async (stream: Stream) => {
|
||||
try {
|
||||
if (stream.url) {
|
||||
logger.log('handleStreamPress called with stream:', {
|
||||
url: stream.url,
|
||||
behaviorHints: stream.behaviorHints,
|
||||
useExternalPlayer,
|
||||
preferredPlayer
|
||||
});
|
||||
|
||||
// For iOS, try to open with the preferred external player
|
||||
if (Platform.OS === 'ios' && preferredPlayer !== 'internal') {
|
||||
try {
|
||||
// Format the URL for the selected player
|
||||
const streamUrl = encodeURIComponent(stream.url);
|
||||
let externalPlayerUrls: string[] = [];
|
||||
|
||||
// Configure URL formats based on the selected player
|
||||
switch (preferredPlayer) {
|
||||
case 'vlc':
|
||||
externalPlayerUrls = [
|
||||
`vlc://${stream.url}`,
|
||||
`vlc-x-callback://x-callback-url/stream?url=${streamUrl}`,
|
||||
`vlc://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'outplayer':
|
||||
externalPlayerUrls = [
|
||||
`outplayer://${stream.url}`,
|
||||
`outplayer://${streamUrl}`,
|
||||
`outplayer://play?url=${streamUrl}`,
|
||||
`outplayer://stream?url=${streamUrl}`,
|
||||
`outplayer://play/browser?url=${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'infuse':
|
||||
externalPlayerUrls = [
|
||||
`infuse://x-callback-url/play?url=${streamUrl}`,
|
||||
`infuse://play?url=${streamUrl}`,
|
||||
`infuse://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'vidhub':
|
||||
externalPlayerUrls = [
|
||||
`vidhub://play?url=${streamUrl}`,
|
||||
`vidhub://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
// If no matching player or the setting is somehow invalid, use internal player
|
||||
navigateToPlayer(stream);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Attempting to open stream in ${preferredPlayer}`);
|
||||
|
||||
// Try each URL format in sequence
|
||||
const tryNextUrl = (index: number) => {
|
||||
if (index >= externalPlayerUrls.length) {
|
||||
console.log(`All ${preferredPlayer} formats failed, falling back to direct URL`);
|
||||
// Try direct URL as last resort
|
||||
Linking.openURL(stream.url)
|
||||
.then(() => console.log('Opened with direct URL'))
|
||||
.catch(() => {
|
||||
console.log('Direct URL failed, falling back to built-in player');
|
||||
navigateToPlayer(stream);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = externalPlayerUrls[index];
|
||||
console.log(`Trying ${preferredPlayer} URL format ${index + 1}: ${url}`);
|
||||
|
||||
Linking.openURL(url)
|
||||
.then(() => console.log(`Successfully opened stream with ${preferredPlayer} format ${index + 1}`))
|
||||
.catch(err => {
|
||||
console.log(`Format ${index + 1} failed: ${err.message}`, err);
|
||||
tryNextUrl(index + 1);
|
||||
});
|
||||
};
|
||||
|
||||
// Start with the first URL format
|
||||
tryNextUrl(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error with ${preferredPlayer}:`, error);
|
||||
// Fallback to the built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}
|
||||
// For Android with external player preference
|
||||
else if (Platform.OS === 'android' && useExternalPlayer) {
|
||||
try {
|
||||
console.log('Opening stream with Android native app chooser');
|
||||
|
||||
// For Android, determine if the URL is a direct http/https URL or a magnet link
|
||||
const isMagnet = stream.url.startsWith('magnet:');
|
||||
|
||||
if (isMagnet) {
|
||||
// For magnet links, open directly which will trigger the torrent app chooser
|
||||
console.log('Opening magnet link directly');
|
||||
Linking.openURL(stream.url)
|
||||
.then(() => console.log('Successfully opened magnet link'))
|
||||
.catch(err => {
|
||||
console.error('Failed to open magnet link:', err);
|
||||
// No good fallback for magnet links
|
||||
navigateToPlayer(stream);
|
||||
});
|
||||
} else {
|
||||
// For direct video URLs, use the S.Browser.ACTION_VIEW approach
|
||||
// This is a more reliable way to force Android to show all video apps
|
||||
|
||||
// Strip query parameters if they exist as they can cause issues with some apps
|
||||
let cleanUrl = stream.url;
|
||||
if (cleanUrl.includes('?')) {
|
||||
cleanUrl = cleanUrl.split('?')[0];
|
||||
}
|
||||
|
||||
// Create an Android intent URL that forces the chooser
|
||||
// Set component=null to ensure chooser is shown
|
||||
// Set action=android.intent.action.VIEW to open the content
|
||||
const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`;
|
||||
|
||||
console.log(`Using intent URL: ${intentUrl}`);
|
||||
|
||||
Linking.openURL(intentUrl)
|
||||
.then(() => console.log('Successfully opened with intent URL'))
|
||||
.catch(err => {
|
||||
console.error('Failed to open with intent URL:', err);
|
||||
|
||||
// First fallback: Try direct URL with regular Linking API
|
||||
console.log('Trying plain URL as fallback');
|
||||
Linking.openURL(stream.url)
|
||||
.then(() => console.log('Opened with direct URL'))
|
||||
.catch(directErr => {
|
||||
console.error('Failed to open direct URL:', directErr);
|
||||
|
||||
// Final fallback: Use built-in player
|
||||
console.log('All external player attempts failed, using built-in player');
|
||||
navigateToPlayer(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error with external player:', error);
|
||||
// Fallback to the built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// For internal player or if other options failed, use the built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in handleStreamPress:', error);
|
||||
// Final fallback: Use built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}, [navigateToPlayer, preferredPlayer, useExternalPlayer]);
|
||||
|
||||
return {
|
||||
handleStreamPress,
|
||||
navigateToPlayer
|
||||
};
|
||||
};
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { stremioService } from '../services/stremioService';
|
||||
import { Stream } from '../types/metadata';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface StreamGroups {
|
||||
[addonId: string]: {
|
||||
addonName: string;
|
||||
streams: Stream[];
|
||||
};
|
||||
}
|
||||
|
||||
export const useStreamProviders = (
|
||||
groupedStreams: StreamGroups,
|
||||
episodeStreams: StreamGroups,
|
||||
type: string,
|
||||
loadingStreams: boolean,
|
||||
loadingEpisodeStreams: boolean
|
||||
) => {
|
||||
const [selectedProvider, setSelectedProvider] = useState('all');
|
||||
const [availableProviders, setAvailableProviders] = useState<Set<string>>(new Set());
|
||||
const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({});
|
||||
const [providerStatus, setProviderStatus] = useState<{
|
||||
[key: string]: {
|
||||
loading: boolean;
|
||||
success: boolean;
|
||||
error: boolean;
|
||||
message: string;
|
||||
timeStarted: number;
|
||||
timeCompleted: number;
|
||||
}
|
||||
}>({});
|
||||
const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({});
|
||||
const [loadStartTime, setLoadStartTime] = useState(0);
|
||||
|
||||
// Update available providers when streams change - converted to useEffect
|
||||
useEffect(() => {
|
||||
const streams = type === 'series' ? episodeStreams : groupedStreams;
|
||||
const providers = new Set(Object.keys(streams));
|
||||
setAvailableProviders(providers);
|
||||
}, [type, groupedStreams, episodeStreams]);
|
||||
|
||||
// Start tracking load time when loading begins - converted to useEffect
|
||||
useEffect(() => {
|
||||
if (loadingStreams || loadingEpisodeStreams) {
|
||||
logger.log("⏱️ Stream loading started");
|
||||
const now = Date.now();
|
||||
setLoadStartTime(now);
|
||||
setProviderLoadTimes({});
|
||||
|
||||
// Reset provider status - only for stremio addons
|
||||
setProviderStatus({
|
||||
'stremio': {
|
||||
loading: true,
|
||||
success: false,
|
||||
error: false,
|
||||
message: 'Loading...',
|
||||
timeStarted: now,
|
||||
timeCompleted: 0
|
||||
}
|
||||
});
|
||||
|
||||
// Also update the simpler loading state - only for stremio
|
||||
setLoadingProviders({
|
||||
'stremio': true
|
||||
});
|
||||
}
|
||||
}, [loadingStreams, loadingEpisodeStreams]);
|
||||
|
||||
// Generate filter items for the provider selector
|
||||
const filterItems = useMemo(() => {
|
||||
const installedAddons = stremioService.getInstalledAddons();
|
||||
const streams = type === 'series' ? episodeStreams : groupedStreams;
|
||||
|
||||
return [
|
||||
{ id: 'all', name: 'All Providers' },
|
||||
...Array.from(availableProviders)
|
||||
.sort((a, b) => {
|
||||
const indexA = installedAddons.findIndex(addon => addon.id === a);
|
||||
const indexB = installedAddons.findIndex(addon => addon.id === b);
|
||||
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map(provider => {
|
||||
const addonInfo = streams[provider];
|
||||
const installedAddon = installedAddons.find(addon => addon.id === provider);
|
||||
|
||||
let displayName = provider;
|
||||
if (installedAddon) displayName = installedAddon.name;
|
||||
else if (addonInfo?.addonName) displayName = addonInfo.addonName;
|
||||
|
||||
return { id: provider, name: displayName };
|
||||
})
|
||||
];
|
||||
}, [availableProviders, type, episodeStreams, groupedStreams]);
|
||||
|
||||
// Filter streams to show only selected provider (or all)
|
||||
const filteredSections = useMemo(() => {
|
||||
const streams = type === 'series' ? episodeStreams : groupedStreams;
|
||||
const installedAddons = stremioService.getInstalledAddons();
|
||||
|
||||
return Object.entries(streams)
|
||||
.filter(([addonId]) => {
|
||||
// If "all" is selected, show all providers
|
||||
if (selectedProvider === 'all') {
|
||||
return true;
|
||||
}
|
||||
// Otherwise only show the selected provider
|
||||
return addonId === selectedProvider;
|
||||
})
|
||||
.sort(([addonIdA], [addonIdB]) => {
|
||||
const indexA = installedAddons.findIndex(addon => addon.id === addonIdA);
|
||||
const indexB = installedAddons.findIndex(addon => addon.id === addonIdB);
|
||||
|
||||
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map(([addonId, { addonName, streams }]) => ({
|
||||
title: addonName,
|
||||
addonId,
|
||||
data: streams
|
||||
}));
|
||||
}, [selectedProvider, type, episodeStreams, groupedStreams]);
|
||||
|
||||
// Handler for changing the selected provider
|
||||
const handleProviderChange = useCallback((provider: string) => {
|
||||
setSelectedProvider(provider);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedProvider,
|
||||
availableProviders,
|
||||
loadingProviders,
|
||||
providerStatus,
|
||||
filterItems,
|
||||
filteredSections,
|
||||
handleProviderChange,
|
||||
setLoadingProviders,
|
||||
setProviderStatus
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue