diff --git a/src/components/streams/EpisodeHero.tsx b/src/components/streams/EpisodeHero.tsx deleted file mode 100644 index 92d8d12f..00000000 --- a/src/components/streams/EpisodeHero.tsx +++ /dev/null @@ -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 ( - - - - - - - - - {currentEpisode.episodeString} - - - {currentEpisode.name} - - {currentEpisode.overview && ( - - {currentEpisode.overview} - - )} - - - {formattedAirDate} - - {currentEpisode.vote_average && currentEpisode.vote_average > 0 && ( - - - - {currentEpisode.vote_average.toFixed(1)} - - - )} - {currentEpisode.runtime && ( - - - - {currentEpisode.runtime >= 60 - ? `${Math.floor(currentEpisode.runtime / 60)}h ${currentEpisode.runtime % 60}m` - : `${currentEpisode.runtime}m`} - - - )} - - - - - - - - - ); -}; - -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); \ No newline at end of file diff --git a/src/components/streams/MovieHero.tsx b/src/components/streams/MovieHero.tsx deleted file mode 100644 index d05c6a83..00000000 --- a/src/components/streams/MovieHero.tsx +++ /dev/null @@ -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 ( - - - - - {metadata.logo ? ( - - ) : ( - - {metadata.name} - - )} - - - - - ); -}; - -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); \ No newline at end of file diff --git a/src/components/streams/ProviderFilter.tsx b/src/components/streams/ProviderFilter.tsx deleted file mode 100644 index 800051f9..00000000 --- a/src/components/streams/ProviderFilter.tsx +++ /dev/null @@ -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 } }) => ( - onSelect(item.id)} - > - - {item.name} - - - ), [selectedProvider, onSelect]); - - return ( - 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); \ No newline at end of file diff --git a/src/components/streams/StreamCard.tsx b/src/components/streams/StreamCard.tsx deleted file mode 100644 index cb69824d..00000000 --- a/src/components/streams/StreamCard.tsx +++ /dev/null @@ -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 ( - - - - - - {displayTitle} - - {displayAddonName && displayAddonName !== displayTitle && ( - - {displayAddonName} - - )} - - - {/* Show loading indicator if stream is loading */} - {isLoading && ( - - - - {statusMessage || "Loading..."} - - - )} - - - - {quality && quality >= "720" && ( - - )} - - {isDolby && ( - - )} - - {size && ( - - {size} - - )} - - {isDebrid && ( - - DEBRID - - )} - - - - - - - - ); -}; - -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); \ No newline at end of file diff --git a/src/hooks/useStreamNavigation.ts b/src/hooks/useStreamNavigation.ts deleted file mode 100644 index b3925830..00000000 --- a/src/hooks/useStreamNavigation.ts +++ /dev/null @@ -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>(); - - 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 - }; -}; \ No newline at end of file diff --git a/src/hooks/useStreamProviders.ts b/src/hooks/useStreamProviders.ts deleted file mode 100644 index d9f4f161..00000000 --- a/src/hooks/useStreamProviders.ts +++ /dev/null @@ -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>(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 - }; -}; \ No newline at end of file diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 56e33caa..3f0f27b8 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -1,53 +1,208 @@ -import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import React, { useCallback, useMemo, memo, useState, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, + FlatList, SectionList, Platform, + ImageBackground, + ScrollView, StatusBar, - Dimensions + 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, - cancelAnimation + runOnJS, + cancelAnimation, + SharedValue } from 'react-native-reanimated'; import { logger } from '../utils/logger'; -// Import custom components -import StreamCard from '../components/streams/StreamCard'; -import ProviderFilter from '../components/streams/ProviderFilter'; -import MovieHero from '../components/streams/MovieHero'; -import EpisodeHero from '../components/streams/EpisodeHero'; +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'; -// Import custom hooks -import { useStreamNavigation } from '../hooks/useStreamNavigation'; -import { useStreamProviders } from '../hooks/useStreamProviders'; +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 ( + + + + + + {displayTitle} + + {displayAddonName && displayAddonName !== displayTitle && ( + + {displayAddonName} + + )} + + + {/* Show loading indicator if stream is loading */} + {isLoading && ( + + + + {statusMessage || "Loading..."} + + + )} + + + + {quality && quality >= "720" && ( + + )} + + {isDolby && ( + + )} + + {size && ( + + {size} + + )} + + {isDebrid && ( + + DEBRID + + )} + + + + + + + + ); +}; + +const QualityTag = React.memo(({ text, color }: { text: string; color: string }) => ( + + {text} + +)); + +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 } }) => ( + onSelect(item.id)} + > + + {item.name} + + + ), [selectedProvider, onSelect]); + + return ( + 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>(); const navigation = useNavigation(); const { id, type, episodeId } = route.params; const { settings } = useSettings(); - - // Track loading initialization to prevent duplicate loads - const [initialLoadComplete, setInitialLoadComplete] = useState(false); + // Add timing logs + const [loadStartTime, setLoadStartTime] = useState(0); + const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({}); + const { metadata, episodes, @@ -62,32 +217,57 @@ export const StreamsScreen = () => { groupedEpisodes, } = useMetadata({ id, type }); + const [selectedProvider, setSelectedProvider] = React.useState('all'); + const [availableProviders, setAvailableProviders] = React.useState>(new Set()); + // Optimize animation values with cleanup const headerOpacity = useSharedValue(0); const heroScale = useSharedValue(0.95); const filterOpacity = useSharedValue(0); - // Use custom hooks - const { - selectedProvider, - filterItems, - filteredSections, - handleProviderChange, - loadingProviders, - providerStatus, - setLoadingProviders - } = useStreamProviders( - groupedStreams, - episodeStreams, - type, - loadingStreams, - loadingEpisodeStreams - ); + // 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; + } + }>({}); - // Load initial streams only once + // Monitor streams loading start useEffect(() => { - if (initialLoadComplete) return; - + 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({ @@ -95,31 +275,30 @@ export const StreamsScreen = () => { }); setSelectedEpisode(episodeId); loadEpisodeStreams(episodeId); - setInitialLoadComplete(true); } else if (type === 'movie') { logger.log(`🎬 Loading movie streams for: ${id}`); setLoadingProviders({ 'stremio': true }); loadStreams(); - setInitialLoadComplete(true); } - }, [ - initialLoadComplete, - type, - episodeId, - id, - loadEpisodeStreams, - loadStreams, - setSelectedEpisode, - setLoadingProviders - ]); + }, [type, episodeId]); - // Animation effects - useEffect(() => { + 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 = 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 () => { @@ -128,29 +307,7 @@ export const StreamsScreen = () => { cancelAnimation(heroScale); cancelAnimation(filterOpacity); }; - }, [headerOpacity, heroScale, filterOpacity]); - - 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]); - - // Use navigation hook - const { handleStreamPress } = useStreamNavigation({ - metadata, - currentEpisode, - id, - type, - selectedEpisode: selectedEpisode || undefined, - useExternalPlayer: settings.useExternalPlayer, - preferredPlayer: settings.preferredPlayer as 'internal' | 'vlc' | 'outplayer' | 'infuse' | 'vidhub' | 'external' - }); + }, []); // Memoize handlers const handleBack = useCallback(() => { @@ -172,6 +329,272 @@ export const StreamsScreen = () => { } }, [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; @@ -226,7 +649,6 @@ export const StreamsScreen = () => { barStyle="light-content" /> - {/* Back Button */} { - {/* Movie Hero */} {type === 'movie' && metadata && ( - + + + + + {metadata.logo ? ( + + ) : ( + + {metadata.name} + + )} + + + + )} - {/* Episode Hero */} {type === 'series' && currentEpisode && ( - + + + + + + + + + {currentEpisode.episodeString} + + + {currentEpisode.name} + + {currentEpisode.overview && ( + + {currentEpisode.overview} + + )} + + + {tmdbService.formatAirDate(currentEpisode.air_date)} + + {currentEpisode.vote_average > 0 && ( + + + + {currentEpisode.vote_average.toFixed(1)} + + + )} + {currentEpisode.runtime && ( + + + + {currentEpisode.runtime >= 60 + ? `${Math.floor(currentEpisode.runtime / 60)}h ${currentEpisode.runtime % 60}m` + : `${currentEpisode.runtime}m`} + + + )} + + + + + + + + )} - {/* Stream List */} - {/* Provider Filter */} {Object.keys(streams).length > 0 && ( { )} - {/* Loading or Empty State */} {isLoading && Object.keys(streams).length === 0 ? ( { ) : ( item.url || `${item.name}-${item.title}`} renderItem={renderItem} renderSectionHeader={renderSectionHeader} @@ -361,6 +880,30 @@ const styles = StyleSheet.create({ 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%', @@ -371,6 +914,10 @@ const styles = StyleSheet.create({ paddingBottom: 16, width: '100%', }, + streamGroup: { + marginBottom: 24, + width: '100%', + }, streamGroupTitle: { color: colors.text, fontSize: 16, @@ -379,6 +926,123 @@ const styles = StyleSheet.create({ 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', @@ -390,6 +1054,90 @@ const styles = StyleSheet.create({ 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, @@ -400,6 +1148,29 @@ const styles = StyleSheet.create({ 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', @@ -412,6 +1183,56 @@ const styles = StyleSheet.create({ 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 React.memo(StreamsScreen); \ No newline at end of file +export default memo(StreamsScreen); \ No newline at end of file