NuvioStreaming_backup_24-10-25/src/screens/StreamsScreen.tsx

1238 lines
No EOL
37 KiB
TypeScript

import React, { useCallback, useMemo, memo, useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
FlatList,
SectionList,
Platform,
ImageBackground,
ScrollView,
StatusBar,
Alert,
Dimensions,
Linking
} from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { Image } from 'expo-image';
import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator';
import { useMetadata } from '../hooks/useMetadata';
import { colors } from '../styles/colors';
import { Stream } from '../types/metadata';
import { tmdbService } from '../services/tmdbService';
import { stremioService } from '../services/stremioService';
import { VideoPlayerService } from '../services/videoPlayerService';
import { useSettings } from '../hooks/useSettings';
import QualityBadge from '../components/metadata/QualityBadge';
import Animated, {
FadeIn,
FadeInDown,
SlideInDown,
withSpring,
withTiming,
useAnimatedStyle,
useSharedValue,
interpolate,
Extrapolate,
runOnJS,
cancelAnimation,
SharedValue
} from 'react-native-reanimated';
import { logger } from '../utils/logger';
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png';
const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_Vision_%28logo%29.svg/512px-Dolby_Vision_%28logo%29.svg.png?20220908042900';
const { width, height } = Dimensions.get('window');
// Extracted Components
const StreamCard = ({ stream, onPress, index, isLoading, statusMessage }: {
stream: Stream;
onPress: () => void;
index: number;
isLoading?: boolean;
statusMessage?: string;
}) => {
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 QualityTag = React.memo(({ text, color }: { text: string; color: string }) => (
<View style={[styles.chip, { backgroundColor: color }]}>
<Text style={styles.chipText}>{text}</Text>
</View>
));
const ProviderFilter = memo(({
selectedProvider,
providers,
onSelect
}: {
selectedProvider: string;
providers: Array<{ id: string; name: string; }>;
onSelect: (id: string) => void;
}) => {
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,
})}
/>
);
});
export const StreamsScreen = () => {
const route = useRoute<RouteProp<RootStackParamList, 'Streams'>>();
const navigation = useNavigation<RootStackNavigationProp>();
const { id, type, episodeId } = route.params;
const { settings } = useSettings();
// Add timing logs
const [loadStartTime, setLoadStartTime] = useState(0);
const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({});
const {
metadata,
episodes,
groupedStreams,
loadingStreams,
episodeStreams,
loadingEpisodeStreams,
selectedEpisode,
loadStreams,
loadEpisodeStreams,
setSelectedEpisode,
groupedEpisodes,
} = useMetadata({ id, type });
const [selectedProvider, setSelectedProvider] = React.useState('all');
const [availableProviders, setAvailableProviders] = React.useState<Set<string>>(new Set());
// Optimize animation values with cleanup
const headerOpacity = useSharedValue(0);
const heroScale = useSharedValue(0.95);
const filterOpacity = useSharedValue(0);
// Add state for provider loading status
const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({});
// Add state for more detailed provider loading tracking
const [providerStatus, setProviderStatus] = useState<{
[key: string]: {
loading: boolean;
success: boolean;
error: boolean;
message: string;
timeStarted: number;
timeCompleted: number;
}
}>({});
// Monitor streams loading start
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]);
React.useEffect(() => {
if (type === 'series' && episodeId) {
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
setLoadingProviders({
'stremio': true
});
setSelectedEpisode(episodeId);
loadEpisodeStreams(episodeId);
} else if (type === 'movie') {
logger.log(`🎬 Loading movie streams for: ${id}`);
setLoadingProviders({
'stremio': true
});
loadStreams();
}
}, [type, episodeId]);
React.useEffect(() => {
const streams = type === 'series' ? episodeStreams : groupedStreams;
const providers = new Set(Object.keys(streams));
setAvailableProviders(providers);
}, [type, groupedStreams, episodeStreams]);
React.useEffect(() => {
// Trigger entrance animations
headerOpacity.value = withTiming(1, { duration: 400 });
heroScale.value = withSpring(1, {
damping: 15,
stiffness: 100,
mass: 0.9,
restDisplacementThreshold: 0.01
});
filterOpacity.value = withTiming(1, { duration: 500 });
return () => {
// Cleanup animations on unmount
cancelAnimation(headerOpacity);
cancelAnimation(heroScale);
cancelAnimation(filterOpacity);
};
}, []);
// Memoize handlers
const handleBack = useCallback(() => {
const cleanup = () => {
headerOpacity.value = withTiming(0, { duration: 200 });
heroScale.value = withTiming(0.95, { duration: 200 });
filterOpacity.value = withTiming(0, { duration: 200 });
};
cleanup();
// For series episodes, always replace current screen with metadata screen
if (type === 'series') {
navigation.replace('Metadata', {
id: id,
type: type
});
} else {
navigation.goBack();
}
}, [navigation, headerOpacity, heroScale, filterOpacity, type, id]);
const handleProviderChange = useCallback((provider: string) => {
setSelectedProvider(provider);
}, []);
const currentEpisode = useMemo(() => {
if (!selectedEpisode) return null;
// Search through all episodes in all seasons
const allEpisodes = Object.values(groupedEpisodes).flat();
return allEpisodes.find(ep =>
ep.stremioId === selectedEpisode ||
`${id}:${ep.season_number}:${ep.episode_number}` === selectedEpisode
);
}, [selectedEpisode, groupedEpisodes, id]);
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]);
// Update handleStreamPress
const handleStreamPress = useCallback(async (stream: Stream) => {
try {
if (stream.url) {
logger.log('handleStreamPress called with stream:', {
url: stream.url,
behaviorHints: stream.behaviorHints,
useExternalPlayer: settings.useExternalPlayer,
preferredPlayer: settings.preferredPlayer
});
// For iOS, try to open with the preferred external player
if (Platform.OS === 'ios' && settings.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 (settings.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 ${settings.preferredPlayer}`);
// Try each URL format in sequence
const tryNextUrl = (index: number) => {
if (index >= externalPlayerUrls.length) {
console.log(`All ${settings.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 ${settings.preferredPlayer} URL format ${index + 1}: ${url}`);
Linking.openURL(url)
.then(() => console.log(`Successfully opened stream with ${settings.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 ${settings.preferredPlayer}:`, error);
// Fallback to the built-in player
navigateToPlayer(stream);
}
}
// For Android with external player preference
else if (Platform.OS === 'android' && settings.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);
}
}, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]);
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]);
const sections = useMemo(() => {
const streams = type === 'series' ? episodeStreams : groupedStreams;
const installedAddons = stremioService.getInstalledAddons();
// Filter streams by selected provider - only if not "all"
const filteredEntries = 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
}));
return filteredEntries;
}, [selectedProvider, type, episodeStreams, groupedStreams]);
const episodeImage = useMemo(() => {
if (!currentEpisode) return null;
if (currentEpisode.still_path) {
return tmdbService.getImageUrl(currentEpisode.still_path, 'original');
}
return metadata?.poster || null;
}, [currentEpisode, metadata]);
const isLoading = type === 'series' ? loadingEpisodeStreams : loadingStreams;
const streams = type === 'series' ? episodeStreams : groupedStreams;
const heroStyle = useAnimatedStyle(() => ({
transform: [{ scale: heroScale.value }],
opacity: headerOpacity.value
}));
const filterStyle = useAnimatedStyle(() => ({
opacity: filterOpacity.value,
transform: [
{
translateY: interpolate(
filterOpacity.value,
[0, 1],
[20, 0],
Extrapolate.CLAMP
)
}
]
}));
const renderItem = useCallback(({ item, index, section }: { item: Stream; index: number; section: any }) => {
const stream = item;
const isLoading = loadingProviders[section.addonId];
return (
<StreamCard
key={`${stream.url}-${index}`}
stream={stream}
onPress={() => handleStreamPress(stream)}
index={index}
isLoading={isLoading}
statusMessage={providerStatus[section.addonId]?.message}
/>
);
}, [handleStreamPress, loadingProviders, providerStatus]);
const renderSectionHeader = useCallback(({ section }: { section: { title: string } }) => (
<Animated.View
entering={FadeIn.duration(300)}
>
<Text style={styles.streamGroupTitle}>{section.title}</Text>
</Animated.View>
), []);
return (
<View style={styles.container}>
<StatusBar
translucent
backgroundColor="transparent"
barStyle="light-content"
/>
<Animated.View
entering={FadeIn.duration(300)}
style={[styles.backButtonContainer]}
>
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
activeOpacity={0.7}
>
<MaterialIcons name="arrow-back" size={24} color="#fff" />
<Text style={styles.backButtonText}>
{type === 'series' ? 'Back to Episodes' : 'Back to Info'}
</Text>
</TouchableOpacity>
</Animated.View>
{type === 'movie' && metadata && (
<Animated.View style={[styles.movieTitleContainer, heroStyle]}>
<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>
)}
{type === 'series' && currentEpisode && (
<Animated.View style={[styles.streamsHeroContainer, heroStyle]}>
<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}>
{tmdbService.formatAirDate(currentEpisode.air_date)}
</Text>
{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>
)}
<View style={[
styles.streamsMainContent,
type === 'movie' && styles.streamsMainContentMovie
]}>
<Animated.View style={[styles.filterContainer, filterStyle]}>
{Object.keys(streams).length > 0 && (
<ProviderFilter
selectedProvider={selectedProvider}
providers={filterItems}
onSelect={handleProviderChange}
/>
)}
</Animated.View>
{isLoading && Object.keys(streams).length === 0 ? (
<Animated.View
entering={FadeIn.duration(300)}
style={styles.loadingContainer}
>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Finding available streams...</Text>
</Animated.View>
) : Object.keys(streams).length === 0 ? (
<Animated.View
entering={FadeIn.duration(300)}
style={styles.noStreams}
>
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
<Text style={styles.noStreamsText}>No streams available</Text>
</Animated.View>
) : (
<View collapsable={false} style={{ flex: 1 }}>
<SectionList
sections={sections}
keyExtractor={(item) => item.url || `${item.name}-${item.title}`}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled={false}
initialNumToRender={8}
maxToRenderPerBatch={4}
windowSize={5}
removeClippedSubviews={false}
contentContainerStyle={styles.streamsContainer}
style={styles.streamsContent}
showsVerticalScrollIndicator={false}
bounces={true}
overScrollMode="never"
ListFooterComponent={
isLoading ? (
<View style={styles.footerLoading}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
</View>
) : null
}
/>
</View>
)}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.darkBackground,
},
backButtonContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 2,
pointerEvents: 'box-none',
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
padding: 14,
paddingTop: Platform.OS === 'android' ? 35 : 45,
},
backButtonText: {
color: colors.highEmphasis,
fontSize: 13,
fontWeight: '600',
},
streamsMainContent: {
flex: 1,
backgroundColor: colors.darkBackground,
paddingTop: 20,
zIndex: 1,
},
streamsMainContentMovie: {
paddingTop: Platform.OS === 'android' ? 90 : 100,
},
filterContainer: {
paddingHorizontal: 16,
paddingBottom: 12,
},
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',
},
streamsContent: {
flex: 1,
width: '100%',
zIndex: 2,
},
streamsContainer: {
paddingHorizontal: 16,
paddingBottom: 16,
width: '100%',
},
streamGroup: {
marginBottom: 24,
width: '100%',
},
streamGroupTitle: {
color: colors.text,
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
marginTop: 0,
backgroundColor: 'transparent',
},
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',
},
progressContainer: {
height: 20,
backgroundColor: colors.transparentLight,
borderRadius: 10,
overflow: 'hidden',
marginBottom: 6,
},
progressBar: {
height: '100%',
backgroundColor: colors.primary,
},
progressText: {
color: colors.highEmphasis,
fontSize: 12,
fontWeight: '600',
marginLeft: 8,
},
streamAction: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.elevation2,
justifyContent: 'center',
alignItems: 'center',
},
skeletonCard: {
opacity: 0.7,
},
skeletonTitle: {
height: 24,
width: '40%',
backgroundColor: colors.transparentLight,
borderRadius: 4,
marginBottom: 16,
},
skeletonIcon: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.transparentLight,
marginRight: 12,
},
skeletonText: {
height: 16,
borderRadius: 4,
marginBottom: 8,
backgroundColor: colors.transparentLight,
},
skeletonTag: {
width: 60,
height: 20,
borderRadius: 4,
marginRight: 8,
backgroundColor: colors.transparentLight,
},
noStreams: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
},
noStreamsText: {
color: colors.textMuted,
fontSize: 16,
marginTop: 16,
},
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,
},
loadingContainer: {
alignItems: 'center',
paddingVertical: 24,
},
loadingText: {
color: colors.primary,
fontSize: 12,
marginLeft: 4,
fontWeight: '500',
},
downloadingIndicator: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.transparentLight,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
marginLeft: 8,
},
downloadingText: {
color: colors.primary,
fontSize: 12,
marginLeft: 4,
fontWeight: '500',
},
loadingIndicator: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
marginLeft: 8,
},
footerLoading: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
footerLoadingText: {
color: colors.primary,
fontSize: 12,
marginLeft: 8,
fontWeight: '500',
},
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,
},
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 memo(StreamsScreen);