From 3b6fb438e31a07eb7dfc7b71c9e652fc61fa1a40 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 3 May 2025 13:06:53 +0530 Subject: [PATCH] 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. --- 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, 1034 insertions(+), 906 deletions(-) create mode 100644 src/components/streams/EpisodeHero.tsx create mode 100644 src/components/streams/MovieHero.tsx create mode 100644 src/components/streams/ProviderFilter.tsx create mode 100644 src/components/streams/StreamCard.tsx create mode 100644 src/hooks/useStreamNavigation.ts create mode 100644 src/hooks/useStreamProviders.ts diff --git a/src/components/streams/EpisodeHero.tsx b/src/components/streams/EpisodeHero.tsx new file mode 100644 index 00000000..92d8d12f --- /dev/null +++ b/src/components/streams/EpisodeHero.tsx @@ -0,0 +1,223 @@ +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 new file mode 100644 index 00000000..d05c6a83 --- /dev/null +++ b/src/components/streams/MovieHero.tsx @@ -0,0 +1,98 @@ +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 new file mode 100644 index 00000000..800051f9 --- /dev/null +++ b/src/components/streams/ProviderFilter.tsx @@ -0,0 +1,80 @@ +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 new file mode 100644 index 00000000..cb69824d --- /dev/null +++ b/src/components/streams/StreamCard.tsx @@ -0,0 +1,181 @@ +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 new file mode 100644 index 00000000..b3925830 --- /dev/null +++ b/src/hooks/useStreamNavigation.ts @@ -0,0 +1,221 @@ +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 new file mode 100644 index 00000000..d9f4f161 --- /dev/null +++ b/src/hooks/useStreamProviders.ts @@ -0,0 +1,146 @@ +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 3f0f27b8..56e33caa 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -1,208 +1,53 @@ -import React, { useCallback, useMemo, memo, useState, useEffect } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator, - FlatList, SectionList, Platform, - ImageBackground, - ScrollView, StatusBar, - Alert, - Dimensions, - Linking + Dimensions } from 'react-native'; import { useRoute, useNavigation } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native'; -import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Image } from 'expo-image'; import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator'; import { useMetadata } from '../hooks/useMetadata'; import { colors } from '../styles/colors'; import { Stream } from '../types/metadata'; -import { tmdbService } from '../services/tmdbService'; -import { stremioService } from '../services/stremioService'; -import { VideoPlayerService } from '../services/videoPlayerService'; import { useSettings } from '../hooks/useSettings'; -import QualityBadge from '../components/metadata/QualityBadge'; import Animated, { FadeIn, - FadeInDown, - SlideInDown, - withSpring, withTiming, useAnimatedStyle, useSharedValue, interpolate, Extrapolate, - runOnJS, - cancelAnimation, - SharedValue + cancelAnimation } from 'react-native-reanimated'; import { logger } from '../utils/logger'; -const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; -const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png'; -const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_Vision_%28logo%29.svg/512px-Dolby_Vision_%28logo%29.svg.png?20220908042900'; +// 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 { 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, - })} - /> - ); -}); +// Import custom hooks +import { useStreamNavigation } from '../hooks/useStreamNavigation'; +import { useStreamProviders } from '../hooks/useStreamProviders'; export const StreamsScreen = () => { const route = useRoute>(); const navigation = useNavigation(); const { id, type, episodeId } = route.params; const { settings } = useSettings(); - - // Add timing logs - const [loadStartTime, setLoadStartTime] = useState(0); - const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({}); + // Track loading initialization to prevent duplicate loads + const [initialLoadComplete, setInitialLoadComplete] = useState(false); + const { metadata, episodes, @@ -217,57 +62,32 @@ 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); - // 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; - } - }>({}); + // Use custom hooks + const { + selectedProvider, + filterItems, + filteredSections, + handleProviderChange, + loadingProviders, + providerStatus, + setLoadingProviders + } = useStreamProviders( + groupedStreams, + episodeStreams, + type, + loadingStreams, + loadingEpisodeStreams + ); - // Monitor streams loading start + // Load initial streams only once useEffect(() => { - if (loadingStreams || loadingEpisodeStreams) { - logger.log("⏱️ Stream loading started"); - const now = Date.now(); - setLoadStartTime(now); - setProviderLoadTimes({}); - - // Reset provider status - only for stremio addons - setProviderStatus({ - 'stremio': { - loading: true, - success: false, - error: false, - message: 'Loading...', - timeStarted: now, - timeCompleted: 0 - } - }); - - // Also update the simpler loading state - only for stremio - setLoadingProviders({ - 'stremio': true - }); - } - }, [loadingStreams, loadingEpisodeStreams]); - - React.useEffect(() => { + if (initialLoadComplete) return; + if (type === 'series' && episodeId) { logger.log(`🎬 Loading episode streams for: ${episodeId}`); setLoadingProviders({ @@ -275,30 +95,31 @@ 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); } - }, [type, episodeId]); + }, [ + initialLoadComplete, + type, + episodeId, + id, + loadEpisodeStreams, + loadStreams, + setSelectedEpisode, + setLoadingProviders + ]); - React.useEffect(() => { - const streams = type === 'series' ? episodeStreams : groupedStreams; - const providers = new Set(Object.keys(streams)); - setAvailableProviders(providers); - }, [type, groupedStreams, episodeStreams]); - - React.useEffect(() => { + // Animation effects + useEffect(() => { // Trigger entrance animations headerOpacity.value = withTiming(1, { duration: 400 }); - heroScale.value = withSpring(1, { - damping: 15, - stiffness: 100, - mass: 0.9, - restDisplacementThreshold: 0.01 - }); + heroScale.value = withTiming(1, { duration: 400 }); filterOpacity.value = withTiming(1, { duration: 500 }); return () => { @@ -307,7 +128,29 @@ 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(() => { @@ -329,272 +172,6 @@ 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; @@ -649,6 +226,7 @@ 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} @@ -880,30 +361,6 @@ 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%', @@ -914,10 +371,6 @@ const styles = StyleSheet.create({ paddingBottom: 16, width: '100%', }, - streamGroup: { - marginBottom: 24, - width: '100%', - }, streamGroupTitle: { color: colors.text, fontSize: 16, @@ -926,123 +379,6 @@ 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', @@ -1054,90 +390,6 @@ 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, @@ -1148,29 +400,6 @@ 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', @@ -1183,56 +412,6 @@ 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 memo(StreamsScreen); \ No newline at end of file +export default React.memo(StreamsScreen); \ No newline at end of file