From dfda3ff38ac6272f7b7efa12783c72a8245a5635 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 14:30:27 +0530 Subject: [PATCH] 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 3b6fb438e31a07eb7dfc7b71c9e652fc61fa1a40. --- src/components/streams/EpisodeHero.tsx | 223 ----- src/components/streams/MovieHero.tsx | 98 --- src/components/streams/ProviderFilter.tsx | 80 -- src/components/streams/StreamCard.tsx | 181 ---- src/hooks/useStreamNavigation.ts | 221 ----- src/hooks/useStreamProviders.ts | 146 ---- src/screens/StreamsScreen.tsx | 991 ++++++++++++++++++++-- 7 files changed, 906 insertions(+), 1034 deletions(-) delete mode 100644 src/components/streams/EpisodeHero.tsx delete mode 100644 src/components/streams/MovieHero.tsx delete mode 100644 src/components/streams/ProviderFilter.tsx delete mode 100644 src/components/streams/StreamCard.tsx delete mode 100644 src/hooks/useStreamNavigation.ts delete mode 100644 src/hooks/useStreamProviders.ts 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