From 3801e80dd90698009183b26596a6b8f6cc57163a Mon Sep 17 00:00:00 2001 From: tapframe Date: Thu, 11 Dec 2025 14:42:05 +0530 Subject: [PATCH] streamscreen to player refactor --- src/components/metadata/SeriesContent.tsx | 212 ++-- src/components/player/AndroidVideoPlayer.tsx | 70 +- src/components/player/KSPlayerCore.tsx | 70 +- src/navigation/AppNavigator.tsx | 2 +- src/screens/StreamsScreen.tsx | 1121 +++++++++--------- 5 files changed, 743 insertions(+), 732 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index c790a16f..152ddbb1 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -52,11 +52,11 @@ const SeriesContentComponent: React.FC = ({ const { settings } = useSettings(); const { width } = useWindowDimensions(); const isDarkMode = useColorScheme() === 'dark'; - + // Enhanced responsive sizing for tablets and TV screens const deviceWidth = Dimensions.get('window').width; const deviceHeight = Dimensions.get('window').height; - + // Determine device type based on width const getDeviceType = useCallback(() => { if (deviceWidth >= BREAKPOINTS.tv) return 'tv'; @@ -64,13 +64,13 @@ const SeriesContentComponent: React.FC = ({ if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet'; return 'phone'; }, [deviceWidth]); - + const deviceType = getDeviceType(); const isTablet = deviceType === 'tablet'; const isLargeTablet = deviceType === 'largeTablet'; const isTV = deviceType === 'tv'; const isLargeScreen = isTablet || isLargeTablet || isTV; - + // Enhanced spacing and padding for seasons section const horizontalPadding = useMemo(() => { switch (deviceType) { @@ -124,7 +124,7 @@ const SeriesContentComponent: React.FC = ({ return 16; } }, [deviceType]); - + // Enhanced season poster sizing const seasonPosterWidth = useMemo(() => { switch (deviceType) { @@ -138,7 +138,7 @@ const SeriesContentComponent: React.FC = ({ return 100; // phone } }, [deviceType]); - + const seasonPosterHeight = useMemo(() => { switch (deviceType) { case 'tv': @@ -151,7 +151,7 @@ const SeriesContentComponent: React.FC = ({ return 150; // phone } }, [deviceType]); - + const seasonButtonSpacing = useMemo(() => { switch (deviceType) { case 'tv': @@ -164,7 +164,7 @@ const SeriesContentComponent: React.FC = ({ return 16; // phone } }, [deviceType]); - + const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({}); // Delay item entering animations to avoid FlashList initial layout glitches const [enableItemAnimations, setEnableItemAnimations] = useState(false); @@ -172,14 +172,14 @@ const SeriesContentComponent: React.FC = ({ const [tmdbEpisodeOverrides, setTmdbEpisodeOverrides] = useState<{ [epKey: string]: { vote_average?: number; runtime?: number; still_path?: string } }>({}); // IMDb ratings for episodes - using a map for O(1) lookups instead of array searches const [imdbRatingsMap, setImdbRatingsMap] = useState<{ [key: string]: number }>({}); - + // Add state for season view mode (persists for current show across navigation) const [seasonViewMode, setSeasonViewMode] = useState<'posters' | 'text'>('posters'); - + // View mode state (no animations) const [posterViewVisible, setPosterViewVisible] = useState(true); const [textViewVisible, setTextViewVisible] = useState(false); - + // Add refs for the scroll views const seasonScrollViewRef = useRef(null); const episodeScrollViewRef = useRef>(null); @@ -198,7 +198,7 @@ const SeriesContentComponent: React.FC = ({ if (__DEV__) console.log('[SeriesContent] Error loading global view mode preference:', error); } }; - + loadViewModePreference(); }, []); @@ -222,17 +222,17 @@ const SeriesContentComponent: React.FC = ({ if (__DEV__) console.log('[SeriesContent] Error saving global view mode preference:', error); }); }; - + // Add refs for the scroll views - + const loadEpisodesProgress = async () => { if (!metadata?.id) return; - + const allProgress = await storageService.getAllWatchProgress(); const progress: { [key: string]: { currentTime: number; duration: number; lastUpdated: number } } = {}; - + episodes.forEach(episode => { const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`; const key = `series:${metadata.id}:${episodeId}`; @@ -244,7 +244,7 @@ const SeriesContentComponent: React.FC = ({ }; } }); - + // ---------------- Trakt watched-history integration ---------------- try { const traktService = TraktService.getInstance(); @@ -254,7 +254,7 @@ const SeriesContentComponent: React.FC = ({ // Each page has up to 100 items by default, fetch enough to cover ~12+ seasons let allHistoryItems: any[] = []; const pageLimit = 10; // Fetch up to 10 pages (max 1000 items) to cover extensive libraries - + for (let page = 1; page <= pageLimit; page++) { const historyItems = await traktService.getWatchedEpisodesHistory(page, 100); if (!historyItems || historyItems.length === 0) { @@ -295,7 +295,7 @@ const SeriesContentComponent: React.FC = ({ } catch (err) { logger.error('[SeriesContent] Failed to merge Trakt history:', err); } - + setEpisodeProgress(progress); }; @@ -304,28 +304,28 @@ const SeriesContentComponent: React.FC = ({ if (!metadata?.id || !settings?.episodeLayoutStyle || settings.episodeLayoutStyle !== 'horizontal') { return; } - + const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || []; if (currentSeasonEpisodes.length === 0) { return; } - + // Find the most recently watched episode in the current season let mostRecentEpisodeIndex = -1; let mostRecentTimestamp = 0; let mostRecentEpisodeName = ''; - + currentSeasonEpisodes.forEach((episode, index) => { const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`; const progress = episodeProgress[episodeId]; - + if (progress && progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) { mostRecentTimestamp = progress.lastUpdated; mostRecentEpisodeIndex = index; mostRecentEpisodeName = episode.name; } }); - + // Scroll to the most recently watched episode if found if (mostRecentEpisodeIndex >= 0) { setTimeout(() => { @@ -369,7 +369,7 @@ const SeriesContentComponent: React.FC = ({ } else { logger.log('[SeriesContent] metadata.id does not start with tmdb: or tt:', metadata.id); } - + if (!tmdbShowId) { logger.warn('[SeriesContent] Could not resolve TMDB show ID, skipping IMDb ratings fetch'); return; @@ -378,10 +378,10 @@ const SeriesContentComponent: React.FC = ({ logger.log('[SeriesContent] Fetching IMDb ratings for TMDB ID:', tmdbShowId); // Fetch IMDb ratings for all seasons const ratings = await tmdbService.getIMDbRatings(tmdbShowId); - + if (ratings) { logger.log('[SeriesContent] IMDb ratings fetched successfully. Seasons:', ratings.length); - + // Create a lookup map for O(1) access: key format "season:episode" -> rating const ratingsMap: { [key: string]: number } = {}; ratings.forEach(season => { @@ -394,7 +394,7 @@ const SeriesContentComponent: React.FC = ({ }); } }); - + logger.log('[SeriesContent] IMDb ratings map created with', Object.keys(ratingsMap).length, 'episodes'); setImdbRatingsMap(ratingsMap); } else { @@ -472,7 +472,7 @@ const SeriesContentComponent: React.FC = ({ return () => { // Clear any pending timeouts if (__DEV__) console.log('[SeriesContent] Component unmounted, cleaning up memory'); - + // Force garbage collection if available (development only) if (__DEV__ && global.gc) { global.gc(); @@ -486,7 +486,7 @@ const SeriesContentComponent: React.FC = ({ // Find the index of the selected season const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b); const selectedIndex = seasons.findIndex(season => season === selectedSeason); - + if (selectedIndex !== -1) { // Wait a small amount of time for layout to be ready setTimeout(() => { @@ -540,11 +540,11 @@ const SeriesContentComponent: React.FC = ({ if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) { return null; } - - if (__DEV__) console.log('[SeriesContent] renderSeasonSelector called, current view mode:', seasonViewMode); - + + + const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b); - + return ( = ({ ]}> Seasons - + {/* Dropdown Toggle Button */} = ({ activeOpacity={0.7} > = ({ - + >} data={seasons} @@ -618,7 +618,7 @@ const SeriesContentComponent: React.FC = ({ windowSize={3} renderItem={({ item: season }) => { const seasonEpisodes = groupedEpisodes[season] || []; - + // Get season poster URL (needed for both views) let seasonPoster = DEFAULT_PLACEHOLDER; if (seasonEpisodes[0]?.season_poster_path) { @@ -627,12 +627,12 @@ const SeriesContentComponent: React.FC = ({ } else if (metadata?.poster) { seasonPoster = metadata.poster; } - + if (seasonViewMode === 'text') { // Text-only view - if (__DEV__) console.log('[SeriesContent] Rendering text view for season:', season, 'View mode ref:', seasonViewMode); + return ( - @@ -666,11 +666,11 @@ const SeriesContentComponent: React.FC = ({ ); } - + // Poster view (current implementation) - if (__DEV__) console.log('[SeriesContent] Rendering poster view for season:', season, 'View mode ref:', seasonViewMode); + return ( - @@ -710,10 +710,10 @@ const SeriesContentComponent: React.FC = ({ )} - = ({ Season {season} - - ); - }} + + ); + }} keyExtractor={season => season.toString()} /> @@ -763,11 +763,11 @@ const SeriesContentComponent: React.FC = ({ }; let episodeImage = resolveEpisodeImage(); - + const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : ''; const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : ''; const episodeString = seasonNumber && episodeNumber ? `S${seasonNumber.padStart(2, '0')}E${episodeNumber.padStart(2, '0')}` : ''; - + const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { @@ -795,9 +795,9 @@ const SeriesContentComponent: React.FC = ({ const tmdbRating = tmdbOverride?.vote_average ?? episode.vote_average; const effectiveVote = imdbRating ?? tmdbRating ?? 0; const isImdbRating = imdbRating !== null; - - logger.log(`[SeriesContent] Vertical card S${episode.season_number}E${episode.episode_number}: IMDb=${imdbRating}, TMDB=${tmdbRating}, effective=${effectiveVote}, isImdb=${isImdbRating}`); - + + + const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime; if (!episode.still_path && tmdbOverride?.still_path) { const tmdbUrl = tmdbService.getImageUrl(tmdbOverride.still_path, 'original'); @@ -805,7 +805,7 @@ const SeriesContentComponent: React.FC = ({ } const progress = episodeProgress[episodeId]; const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; - + // Don't show progress bar if episode is complete (>= 85%) const showProgress = progress && progressPercent < 85; @@ -813,8 +813,8 @@ const SeriesContentComponent: React.FC = ({ = ({ {showProgress && ( - )} @@ -907,7 +907,7 @@ const SeriesContentComponent: React.FC = ({ ]}> = ({ )} - = ({ }; let episodeImage = resolveEpisodeImage(); - + const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : ''; const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : ''; const episodeString = seasonNumber && episodeNumber ? `EPISODE ${episodeNumber}` : ''; - + const formatRuntime = (runtime: number) => { if (!runtime) return null; const hours = Math.floor(runtime / 60); @@ -1066,9 +1066,7 @@ const SeriesContentComponent: React.FC = ({ const effectiveVote = imdbRating ?? tmdbRating ?? 0; const isImdbRating = imdbRating !== null; const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime; - - logger.log(`[SeriesContent] Horizontal card S${episode.season_number}E${episode.episode_number}: IMDb=${imdbRating}, TMDB=${tmdbRating}, effective=${effectiveVote}, isImdb=${isImdbRating}`); - + const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { @@ -1077,10 +1075,10 @@ const SeriesContentComponent: React.FC = ({ year: 'numeric' }); }; - + const progress = episodeProgress[episodeId]; const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; - + // Don't show progress bar if episode is complete (>= 85%) const showProgress = progress && progressPercent < 85; @@ -1097,7 +1095,7 @@ const SeriesContentComponent: React.FC = ({ shadowRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8 }, // Gradient border styling - { + { borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)', shadowColor: '#000', @@ -1115,12 +1113,12 @@ const SeriesContentComponent: React.FC = ({ style={styles.episodeBackgroundImage} resizeMode={FastImage.resizeMode.cover} /> - + {/* Standard Gradient Overlay */} = ({ marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6 } ]}> - {episodeString} + {episodeString} - + {/* Episode Title */} = ({ ]} numberOfLines={2}> {episode.name} - + {/* Episode Description */} - = ({ ]} numberOfLines={isLargeScreen ? 4 : 3}> {(episode.overview || (episode as any).description || (episode as any).plot || (episode as any).synopsis || 'No description available')} - + {/* Metadata Row */} = ({ )} - + {/* Progress Bar */} {showProgress && ( - )} - + {/* Completed Badge */} {progressPercent >= 85 && ( = ({ opacity: 0.9, }} /> )} - + ); @@ -1314,13 +1312,13 @@ const SeriesContentComponent: React.FC = ({ return ( - {renderSeasonSelector()} - - = ({ ]}> {currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'} - + {/* Show message when no episodes are available for selected season */} {currentSeasonEpisodes.length === 0 && ( @@ -1347,7 +1345,7 @@ const SeriesContentComponent: React.FC = ({ )} - + {/* Only render episode list if there are episodes */} {currentSeasonEpisodes.length > 0 && ( (settings?.episodeLayoutStyle === 'horizontal') ? ( @@ -1417,7 +1415,7 @@ const SeriesContentComponent: React.FC = ({ ref={episodeScrollViewRef} data={currentSeasonEpisodes} renderItem={({ item: episode, index }) => ( - {renderVerticalEpisodeCard(episode)} @@ -1474,7 +1472,7 @@ const styles = StyleSheet.create({ episodeList: { flex: 1, }, - + // Vertical Layout Styles episodeListContentVertical: { paddingBottom: 8, diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 604da708..7e430731 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, Image } from 'react-native'; +import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, Image, InteractionManager } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType, ViewType } from 'react-native-video'; import FastImage from '@d11/react-native-fast-image'; @@ -641,43 +641,51 @@ const AndroidVideoPlayer: React.FC = () => { // Prefetch backdrop and title logo for faster loading screen appearance useEffect(() => { - if (backdrop && typeof backdrop === 'string') { - // Reset loading state - setIsBackdropLoaded(false); - backdropImageOpacityAnim.setValue(0); + // Defer prefetching until after navigation animation completes + const task = InteractionManager.runAfterInteractions(() => { + if (backdrop && typeof backdrop === 'string') { + // Reset loading state + setIsBackdropLoaded(false); + backdropImageOpacityAnim.setValue(0); - // Prefetch the image - try { - FastImage.preload([{ uri: backdrop }]); - // Image prefetch initiated, fade it in smoothly + // Prefetch the image + try { + FastImage.preload([{ uri: backdrop }]); + // Image prefetch initiated, fade it in smoothly + setIsBackdropLoaded(true); + Animated.timing(backdropImageOpacityAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }).start(); + } catch (error) { + // If prefetch fails, still show the image but without animation + if (__DEV__) logger.warn('[AndroidVideoPlayer] Backdrop prefetch failed, showing anyway:', error); + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(1); + } + } else { + // No backdrop provided, consider it "loaded" setIsBackdropLoaded(true); - Animated.timing(backdropImageOpacityAnim, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }).start(); - } catch (error) { - // If prefetch fails, still show the image but without animation - if (__DEV__) logger.warn('[AndroidVideoPlayer] Backdrop prefetch failed, showing anyway:', error); - setIsBackdropLoaded(true); - backdropImageOpacityAnim.setValue(1); + backdropImageOpacityAnim.setValue(0); } - } else { - // No backdrop provided, consider it "loaded" - setIsBackdropLoaded(true); - backdropImageOpacityAnim.setValue(0); - } + }); + return () => task.cancel(); }, [backdrop]); useEffect(() => { - const logoUrl = (metadata && (metadata as any).logo) as string | undefined; - if (logoUrl && typeof logoUrl === 'string') { - try { - FastImage.preload([{ uri: logoUrl }]); - } catch (error) { - // Silently ignore logo prefetch errors + // Defer logo prefetch until after navigation animation + const task = InteractionManager.runAfterInteractions(() => { + const logoUrl = (metadata && (metadata as any).logo) as string | undefined; + if (logoUrl && typeof logoUrl === 'string') { + try { + FastImage.preload([{ uri: logoUrl }]); + } catch (error) { + // Silently ignore logo prefetch errors + } } - } + }); + return () => task.cancel(); }, [metadata]); // Resolve current episode description for series diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 95a73ee7..ede1699d 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState } from 'react-native'; +import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, InteractionManager } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; import FastImage from '@d11/react-native-fast-image'; @@ -342,43 +342,51 @@ const KSPlayerCore: React.FC = () => { // Load custom backdrop on mount // Prefetch backdrop and title logo for faster loading screen appearance useEffect(() => { - if (backdrop && typeof backdrop === 'string') { - // Reset loading state - setIsBackdropLoaded(false); - backdropImageOpacityAnim.setValue(0); + // Defer prefetching until after navigation animation completes + const task = InteractionManager.runAfterInteractions(() => { + if (backdrop && typeof backdrop === 'string') { + // Reset loading state + setIsBackdropLoaded(false); + backdropImageOpacityAnim.setValue(0); - // Prefetch the image - try { - FastImage.preload([{ uri: backdrop }]); - // Image prefetch initiated, fade it in smoothly + // Prefetch the image + try { + FastImage.preload([{ uri: backdrop }]); + // Image prefetch initiated, fade it in smoothly + setIsBackdropLoaded(true); + Animated.timing(backdropImageOpacityAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }).start(); + } catch (error) { + // If prefetch fails, still show the image but without animation + if (__DEV__) logger.warn('[VideoPlayer] Backdrop prefetch failed, showing anyway:', error); + setIsBackdropLoaded(true); + backdropImageOpacityAnim.setValue(1); + } + } else { + // No backdrop provided, consider it "loaded" setIsBackdropLoaded(true); - Animated.timing(backdropImageOpacityAnim, { - toValue: 1, - duration: 400, - useNativeDriver: true, - }).start(); - } catch (error) { - // If prefetch fails, still show the image but without animation - if (__DEV__) logger.warn('[VideoPlayer] Backdrop prefetch failed, showing anyway:', error); - setIsBackdropLoaded(true); - backdropImageOpacityAnim.setValue(1); + backdropImageOpacityAnim.setValue(0); } - } else { - // No backdrop provided, consider it "loaded" - setIsBackdropLoaded(true); - backdropImageOpacityAnim.setValue(0); - } + }); + return () => task.cancel(); }, [backdrop]); useEffect(() => { - const logoUrl = (metadata && (metadata as any).logo) as string | undefined; - if (logoUrl && typeof logoUrl === 'string') { - try { - FastImage.preload([{ uri: logoUrl }]); - } catch (error) { - // Silently ignore logo prefetch errors + // Defer logo prefetch until after navigation animation + const task = InteractionManager.runAfterInteractions(() => { + const logoUrl = (metadata && (metadata as any).logo) as string | undefined; + if (logoUrl && typeof logoUrl === 'string') { + try { + FastImage.preload([{ uri: logoUrl }]); + } catch (error) { + // Silently ignore logo prefetch errors + } } - } + }); + return () => task.cancel(); }, [metadata]); // Log video source configuration with headers diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 11566331..142e53a7 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -1210,7 +1210,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta options={{ animation: 'default', animationDuration: 0, - // Force fullscreen presentation on iPad + // fullScreenModal required for proper video rendering on iOS presentation: 'fullScreenModal', // Disable gestures during video playback gestureEnabled: false, diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 32a23b1b..791545dd 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -16,12 +16,12 @@ import { Clipboard, Image as RNImage, } from 'react-native'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, withDelay, - runOnJS + runOnJS } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -110,7 +110,7 @@ const detectMkvViaHead = async (url: string, headers?: Record) = const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); - + return ( {text} @@ -149,7 +149,7 @@ export const StreamsScreen = () => { const loadStartTimeRef = useRef(0); const hasDoneInitialLoadRef = useRef(false); const isLoadingStreamsRef = useRef(false); - + // CustomAlert state const [alertVisible, setAlertVisible] = useState(false); const [alertTitle, setAlertTitle] = useState(''); @@ -165,23 +165,23 @@ export const StreamsScreen = () => { if (!isMounted.current) { return; } - + try { setAlertTitle(title); setAlertMessage(message); - setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); + setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]); setAlertVisible(true); } catch (error) { console.warn('[StreamsScreen] Error showing alert:', error); } }, []); - + // Track when we started fetching streams so we can show an extended loading state const [streamsLoadStart, setStreamsLoadStart] = useState(null); - const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({}); - + const [providerLoadTimes, setProviderLoadTimes] = useState<{ [key: string]: number }>({}); + // Prevent excessive re-renders by using this guard const guardedSetState = useCallback((setter: () => void) => { if (isMounted.current) { @@ -202,7 +202,7 @@ export const StreamsScreen = () => { useEffect(() => { // Pause trailer when component mounts pauseTrailer(); - + // Resume trailer when component unmounts return () => { resumeTrailer(); @@ -228,7 +228,7 @@ export const StreamsScreen = () => { } = useMetadata({ id, type }); // Get backdrop from metadata assets - const setMetadataStub = useCallback(() => {}, []); + const setMetadataStub = useCallback(() => { }, []); const memoizedSettings = useMemo(() => settings, [settings.logoSourcePreference, settings.tmdbLanguagePreference, settings.enrichMetadataWithTMDB]); const { bannerImage } = useMetadataAssets(metadata, id, type, imdbId, memoizedSettings, setMetadataStub); @@ -240,8 +240,8 @@ export const StreamsScreen = () => { // Add state for provider loading status - const [loadingProviders, setLoadingProviders] = useState<{[key: string]: boolean}>({}); - + const [loadingProviders, setLoadingProviders] = useState<{ [key: string]: boolean }>({}); + // Add state for more detailed provider loading tracking const [providerStatus, setProviderStatus] = useState<{ [key: string]: { @@ -264,10 +264,10 @@ export const StreamsScreen = () => { // Add state for no sources error const [showNoSourcesError, setShowNoSourcesError] = useState(false); - + // State for movie logo loading error const [movieLogoError, setMovieLogoError] = useState(false); - + // Scraper logos map to avoid per-card async fetches const [scraperLogos, setScraperLogos] = useState>({}); // Preload scraper logos once and expose via state @@ -296,7 +296,7 @@ export const StreamsScreen = () => { const map: Record = {}; // No direct way to iterate Map keys safely without exposing it; copy known ids on demand during render setScraperLogos(prev => prev); // no-op to ensure consistency - }).catch(() => {}); + }).catch(() => { }); } }; preloadScraperLogos(); @@ -306,19 +306,19 @@ export const StreamsScreen = () => { useEffect(() => { // Skip processing if component is unmounting if (!isMounted.current) return; - + const currentStreamsData = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; if (__DEV__) console.log('[StreamsScreen] streams state changed', { providerKeys: Object.keys(currentStreamsData || {}), type }); - + // Update available providers immediately when streams change const providersWithStreams = Object.entries(currentStreamsData) .filter(([_, data]) => data.streams && data.streams.length > 0) .map(([providerId]) => providerId); - + if (providersWithStreams.length > 0) { logger.log(`πŸ“Š Providers with streams: ${providersWithStreams.join(', ')}`); const providersWithStreamsSet = new Set(providersWithStreams); - + // Only update if we have new providers, don't remove existing ones during loading setAvailableProviders(prevProviders => { const newProviders = new Set([...prevProviders, ...providersWithStreamsSet]); @@ -326,27 +326,27 @@ export const StreamsScreen = () => { return newProviders; }); } - + // Update loading states for individual providers const expectedProviders = ['stremio']; const now = Date.now(); - + setLoadingProviders(prevLoading => { const nextLoading = { ...prevLoading }; let changed = false; expectedProviders.forEach(providerId => { const providerExists = currentStreamsData[providerId]; const hasStreams = providerExists && - currentStreamsData[providerId].streams && - currentStreamsData[providerId].streams.length > 0; - + currentStreamsData[providerId].streams && + currentStreamsData[providerId].streams.length > 0; + // Stop loading if: // 1. Provider exists (completed) and has streams, OR // 2. Provider exists (completed) but has 0 streams, OR // 3. Overall loading is false const shouldStopLoading = providerExists || !(loadingStreams || loadingEpisodeStreams); const value = !shouldStopLoading; - + if (nextLoading[providerId] !== value) { nextLoading[providerId] = value; changed = true; @@ -355,7 +355,7 @@ export const StreamsScreen = () => { if (changed && __DEV__) console.log('[StreamsScreen] loadingProviders ->', nextLoading); return changed ? nextLoading : prevLoading; }); - + }, [loadingStreams, loadingEpisodeStreams, groupedStreams, episodeStreams, type]); // Reset autoplay state when episode changes (but preserve fromPlayer logic) @@ -378,8 +378,8 @@ export const StreamsScreen = () => { // Check if provider exists in current streams data const currentStreamsData = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; const hasStreamsForProvider = currentStreamsData[selectedProvider] && - currentStreamsData[selectedProvider].streams && - currentStreamsData[selectedProvider].streams.length > 0; + currentStreamsData[selectedProvider].streams && + currentStreamsData[selectedProvider].streams.length > 0; // Only reset if the provider doesn't exist in available providers AND doesn't have streams const isAvailableProvider = availableProviders.has(selectedProvider); @@ -435,54 +435,54 @@ export const StreamsScreen = () => { }, 500); return () => clearTimeout(timer); } else { - // Removed cached streams pre-display logic + // Removed cached streams pre-display logic - // For series episodes, do not wait for metadata; load directly when episodeId is present - if (episodeId) { - logger.log(`🎬 Loading episode streams for: ${episodeId}`); - setLoadingProviders({ - 'stremio': true - }); - setSelectedEpisode(episodeId); - setStreamsLoadStart(Date.now()); - if (__DEV__) console.log('[StreamsScreen] calling loadEpisodeStreams', episodeId); - loadEpisodeStreams(episodeId); - } else if (type === 'movie') { - logger.log(`🎬 Loading movie streams for: ${id}`); - setStreamsLoadStart(Date.now()); - if (__DEV__) console.log('[StreamsScreen] calling loadStreams (movie)', id); - loadStreams(); - } else if (type === 'tv') { - // TV/live content – fetch streams directly - logger.log(`πŸ“Ί Loading TV streams for: ${id}`); - setLoadingProviders({ - 'stremio': true - }); - setStreamsLoadStart(Date.now()); - if (__DEV__) console.log('[StreamsScreen] calling loadStreams (tv)', id); - loadStreams(); - } else { - // Fallback: series without explicit episodeId (or other types) – fetch streams directly - logger.log(`🎬 Loading streams for: ${id}`); - setLoadingProviders({ - 'stremio': true - }); - setStreamsLoadStart(Date.now()); - if (__DEV__) console.log('[StreamsScreen] calling loadStreams (fallback)', id); - loadStreams(); - } + // For series episodes, do not wait for metadata; load directly when episodeId is present + if (episodeId) { + logger.log(`🎬 Loading episode streams for: ${episodeId}`); + setLoadingProviders({ + 'stremio': true + }); + setSelectedEpisode(episodeId); + setStreamsLoadStart(Date.now()); + if (__DEV__) console.log('[StreamsScreen] calling loadEpisodeStreams', episodeId); + loadEpisodeStreams(episodeId); + } else if (type === 'movie') { + logger.log(`🎬 Loading movie streams for: ${id}`); + setStreamsLoadStart(Date.now()); + if (__DEV__) console.log('[StreamsScreen] calling loadStreams (movie)', id); + loadStreams(); + } else if (type === 'tv') { + // TV/live content – fetch streams directly + logger.log(`πŸ“Ί Loading TV streams for: ${id}`); + setLoadingProviders({ + 'stremio': true + }); + setStreamsLoadStart(Date.now()); + if (__DEV__) console.log('[StreamsScreen] calling loadStreams (tv)', id); + loadStreams(); + } else { + // Fallback: series without explicit episodeId (or other types) – fetch streams directly + logger.log(`🎬 Loading streams for: ${id}`); + setLoadingProviders({ + 'stremio': true + }); + setStreamsLoadStart(Date.now()); + if (__DEV__) console.log('[StreamsScreen] calling loadStreams (fallback)', id); + loadStreams(); + } - // Reset autoplay state when content changes - setAutoplayTriggered(false); - if (settings.autoplayBestStream && !fromPlayer) { - setIsAutoplayWaiting(true); - logger.log('πŸ”„ Autoplay enabled, waiting for best stream...'); - } else { - setIsAutoplayWaiting(false); - if (fromPlayer) { - logger.log('🚫 Autoplay disabled: returning from player'); - } + // Reset autoplay state when content changes + setAutoplayTriggered(false); + if (settings.autoplayBestStream && !fromPlayer) { + setIsAutoplayWaiting(true); + logger.log('πŸ”„ Autoplay enabled, waiting for best stream...'); + } else { + setIsAutoplayWaiting(false); + if (fromPlayer) { + logger.log('🚫 Autoplay disabled: returning from player'); } + } } } finally { isLoadingStreamsRef.current = false; @@ -561,7 +561,7 @@ export const StreamsScreen = () => { // Check if any excluded language is found in the stream title or description const hasExcludedLanguage = settings.excludedLanguages.some(excludedLanguage => { const langLower = excludedLanguage.toLowerCase(); - + // Check multiple variations of the language name const variations = [langLower]; @@ -595,9 +595,9 @@ export const StreamsScreen = () => { } else if (langLower === 'hindi') { variations.push('hin'); } - + const matches = variations.some(variant => searchText.includes(variant)); - + if (matches) { console.log(`πŸ” [filterStreamsByLanguage] βœ• Excluding stream with ${excludedLanguage}:`, streamName.substring(0, 100)); } @@ -623,19 +623,19 @@ export const StreamsScreen = () => { // Helper function to extract quality as number const getQualityNumeric = (title: string | undefined): number => { if (!title) return 0; - + // Check for 4K first (treat as 2160p) if (/\b4k\b/i.test(title)) { return 2160; } - + const matchWithP = title.match(/(\d+)p/i); if (matchWithP) return parseInt(matchWithP[1], 10); - + const qualityPatterns = [ /\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i ]; - + for (const pattern of qualityPatterns) { const match = title.match(pattern); if (match) { @@ -651,12 +651,12 @@ export const StreamsScreen = () => { // Get Stremio addon installation order (earlier = higher priority) const installedAddons = stremioService.getInstalledAddons(); const addonIndex = installedAddons.findIndex(addon => addon.id === addonId); - + if (addonIndex !== -1) { // Higher priority for addons installed earlier (reverse index) return 50 - addonIndex; } - + return 0; // Unknown providers get lowest priority }; @@ -671,7 +671,7 @@ export const StreamsScreen = () => { // Apply quality and language filtering to streams before processing const qualityFiltered = filterStreamsByQuality(streams); const filteredStreams = filterStreamsByLanguage(qualityFiltered); - + filteredStreams.forEach(stream => { const quality = getQualityNumeric(stream.name || stream.title); const providerPriority = getProviderPriority(addonId); @@ -701,7 +701,7 @@ export const StreamsScreen = () => { }); logger.log(`🎯 Best stream selected: ${allStreams[0].stream.name || allStreams[0].stream.title} (Quality: ${allStreams[0].quality}p, Provider Priority: ${allStreams[0].providerPriority})`); - + return allStreams[0].stream; }, [filterStreamsByQuality]); @@ -710,8 +710,8 @@ export const StreamsScreen = () => { // Search through all episodes in all seasons const allEpisodes = Object.values(groupedEpisodes).flat(); - return allEpisodes.find(ep => - ep.stremioId === selectedEpisode || + return allEpisodes.find(ep => + ep.stremioId === selectedEpisode || `${id}:${ep.season_number}:${ep.episode_number}` === selectedEpisode ); }, [selectedEpisode, groupedEpisodes, id]); @@ -776,7 +776,7 @@ export const StreamsScreen = () => { // Fetch IMDb ratings for all seasons const ratings = await tmdbService.getIMDbRatings(tmdbShowId); - + if (ratings) { // Create a lookup map for O(1) access: key format "season:episode" -> rating const ratingsMap: { [key: string]: number } = {}; @@ -790,7 +790,7 @@ export const StreamsScreen = () => { }); } }); - + setImdbRatingsMap(ratingsMap); } } catch (err) { @@ -805,18 +805,18 @@ export const StreamsScreen = () => { // Filter headers for Vidrock - only send essential headers const filterHeadersForVidrock = (headers: Record | undefined): Record | undefined => { if (!headers) return undefined; - + // Only keep essential headers for Vidrock const essentialHeaders: Record = {}; if (headers['User-Agent']) essentialHeaders['User-Agent'] = headers['User-Agent']; if (headers['Referer']) essentialHeaders['Referer'] = headers['Referer']; if (headers['Origin']) essentialHeaders['Origin'] = headers['Origin']; - + return Object.keys(essentialHeaders).length > 0 ? essentialHeaders : undefined; }; const finalHeaders = filterHeadersForVidrock(options?.headers || stream.headers); - + // Add logging here console.log('[StreamsScreen] Navigating to player with headers:', { streamHeaders: stream.headers, @@ -825,17 +825,14 @@ export const StreamsScreen = () => { streamUrl: stream.url, streamName: stream.name || stream.title }); - - // Add 50ms delay before navigating to player - await new Promise(resolve => setTimeout(resolve, 50)); - + // Prepare available streams for the change source feature const streamsToPass = (type === 'series' || (type === 'other' && selectedEpisode)) ? episodeStreams : groupedStreams; - + // Determine the stream name using the same logic as StreamCard const streamName = stream.name || stream.title || 'Unnamed Stream'; const streamProvider = stream.addonId || stream.addonName || stream.name; - + // Do NOT pre-force VLC. Let ExoPlayer try first; fallback occurs on decoder error in the player. let forceVlc = !!options?.forceVlc; @@ -845,7 +842,7 @@ export const StreamsScreen = () => { const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined; const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined; const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined; - + await streamCacheService.saveStreamToCache( id, type, @@ -864,7 +861,7 @@ export const StreamsScreen = () => { // Show a quick full-screen black overlay to mask rotation flicker // by setting a transient state that renders a covering View (implementation already supported by dark backgrounds) - + // Infer video type for player (helps Android ExoPlayer choose correct extractor) const inferVideoTypeFromUrl = (u?: string): string | undefined => { if (!u) return undefined; @@ -881,11 +878,11 @@ export const StreamsScreen = () => { if (!videoType && /xprime/i.test(providerId)) { videoType = 'm3u8'; } - } catch {} + } catch { } // Simple platform check - iOS uses KSPlayerCore, Android uses AndroidVideoPlayer const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; - + navigation.navigate(playerRoute as any, { uri: stream.url, title: metadata?.name || '', @@ -920,7 +917,7 @@ export const StreamsScreen = () => { if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) { try { openAlert('Not supported', 'Torrent streaming is not supported yet.'); - } catch (_e) {} + } catch (_e) { } return; } // If stream is actually MKV format, force the in-app VLC-based player on iOS @@ -975,14 +972,14 @@ export const StreamsScreen = () => { 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': @@ -992,7 +989,7 @@ export const StreamsScreen = () => { `vlc://${streamUrl}` ]; break; - + case 'outplayer': externalPlayerUrls = [ `outplayer://${stream.url}`, @@ -1002,7 +999,7 @@ export const StreamsScreen = () => { `outplayer://play/browser?url=${streamUrl}` ]; break; - + case 'infuse': externalPlayerUrls = [ `infuse://x-callback-url/play?url=${streamUrl}`, @@ -1010,14 +1007,14 @@ export const StreamsScreen = () => { `infuse://${streamUrl}` ]; break; - + case 'vidhub': externalPlayerUrls = [ `vidhub://play?url=${streamUrl}`, `vidhub://${streamUrl}` ]; break; - + case 'infuse_livecontainer': const infuseUrls = [ `infuse://x-callback-url/play?url=${streamUrl}`, @@ -1029,15 +1026,15 @@ export const StreamsScreen = () => { return `livecontainer://open-url?url=${encoded}`; }); break; - + default: // If no matching player or the setting is somehow invalid, use internal player navigateToPlayer(stream); return; } - + if (__DEV__) console.log(`Attempting to open stream in ${settings.preferredPlayer}`); - + // Try each URL format in sequence const tryNextUrl = (index: number) => { if (index >= externalPlayerUrls.length) { @@ -1051,10 +1048,10 @@ export const StreamsScreen = () => { }); return; } - + const url = externalPlayerUrls[index]; if (__DEV__) console.log(`Trying ${settings.preferredPlayer} URL format ${index + 1}: ${url}`); - + Linking.openURL(url) .then(() => { if (__DEV__) console.log(`Successfully opened stream with ${settings.preferredPlayer} format ${index + 1}`); }) .catch(err => { @@ -1062,31 +1059,31 @@ export const StreamsScreen = () => { tryNextUrl(index + 1); }); }; - + // Start with the first URL format tryNextUrl(0); - + } catch (error) { if (__DEV__) 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 { if (__DEV__) 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 = typeof stream.url === 'string' && stream.url.startsWith('magnet:'); - + if (isMagnet) { // For magnet links, open directly which will trigger the torrent app chooser if (__DEV__) console.log('Opening magnet link directly'); Linking.openURL(stream.url) .then(() => { if (__DEV__) console.log('Successfully opened magnet link'); }) - .catch(err => { - if (__DEV__) console.error('Failed to open magnet link:', err); + .catch(err => { + if (__DEV__) console.error('Failed to open magnet link:', err); // No good fallback for magnet links navigateToPlayer(stream); }); @@ -1098,10 +1095,10 @@ export const StreamsScreen = () => { episodeTitle: (type === 'series' || type === 'other') ? currentEpisode?.name : undefined, episodeNumber: (type === 'series' || type === 'other') && currentEpisode ? `S${currentEpisode.season_number}E${currentEpisode.episode_number}` : undefined, }); - + if (!success) { if (__DEV__) console.log('VideoPlayerService failed, falling back to built-in player'); - navigateToPlayer(stream); + navigateToPlayer(stream); } } } catch (error) { @@ -1132,32 +1129,32 @@ export const StreamsScreen = () => { // Trigger a small state update to force re-render setStreamsLoadStart(prev => prev); }, 100); - + return () => { clearTimeout(renderTimer); }; } - return () => {}; + return () => { }; }, []) ); // Autoplay effect - triggers immediately when streams are available and autoplay is enabled useEffect(() => { if ( - settings.autoplayBestStream && - !autoplayTriggered && + settings.autoplayBestStream && + !autoplayTriggered && isAutoplayWaiting ) { const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; - + if (Object.keys(streams).length > 0) { const bestStream = getBestStream(streams); - + if (bestStream) { logger.log('πŸš€ Autoplay: Best stream found, starting playback immediately...'); setAutoplayTriggered(true); setIsAutoplayWaiting(false); - + // Start playback immediately - no delay needed handleStreamPress(bestStream); } else { @@ -1180,20 +1177,20 @@ export const StreamsScreen = () => { const filterItems = useMemo(() => { const installedAddons = stremioService.getInstalledAddons(); const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; - + // Only include providers that actually have streams const providersWithStreams = Object.keys(streams).filter(key => { const providerData = streams[key]; if (!providerData || !providerData.streams) { return false; } - + // Only show providers (addons or plugins) if they have actual streams return providerData.streams.length > 0; }); - + const allProviders = new Set([ - ...Array.from(availableProviders).filter((provider: string) => + ...Array.from(availableProviders).filter((provider: string) => streams[provider] && streams[provider].streams && streams[provider].streams.length > 0 ), ...providersWithStreams @@ -1203,7 +1200,7 @@ export const StreamsScreen = () => { if (settings.streamDisplayMode === 'grouped') { const addonProviders: string[] = []; const pluginProviders: string[] = []; - + Array.from(allProviders).forEach(provider => { const isInstalledAddon = installedAddons.some(addon => addon.id === provider); if (isInstalledAddon) { @@ -1212,9 +1209,9 @@ export const StreamsScreen = () => { pluginProviders.push(provider); } }); - + const filterChips = [{ id: 'all', name: 'All Providers' }]; - + // Add individual addon chips addonProviders .sort((a, b) => { @@ -1226,12 +1223,12 @@ export const StreamsScreen = () => { const installedAddon = installedAddons.find(addon => addon.id === provider); filterChips.push({ id: provider, name: installedAddon?.name || provider }); }); - + // Add single grouped plugins chip if there are any plugins with streams if (pluginProviders.length > 0) { filterChips.push({ id: 'grouped-plugins', name: localScraperService.getRepositoryName() }); } - + return filterChips; } @@ -1243,7 +1240,7 @@ export const StreamsScreen = () => { // Sort by Stremio addon installation order 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; @@ -1251,14 +1248,14 @@ export const StreamsScreen = () => { }) .map(provider => { const addonInfo = streams[provider]; - + // Standard handling for Stremio addons 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 }; }) ]; @@ -1267,7 +1264,7 @@ export const StreamsScreen = () => { const sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null> = useMemo(() => { const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; const installedAddons = stremioService.getInstalledAddons(); - + console.log('πŸ” [StreamsScreen] Sections debug:', { streamsKeys: Object.keys(streams), installedAddons: installedAddons.map(a => ({ id: a.id, name: a.name })), @@ -1297,7 +1294,7 @@ export const StreamsScreen = () => { // Otherwise only show the selected provider return addonId === selectedProvider; }); - + console.log('πŸ” [StreamsScreen] Filtered entries:', { filteredCount: filteredEntries.length, filteredEntries: filteredEntries.map(([addonId, data]) => ({ @@ -1306,24 +1303,24 @@ export const StreamsScreen = () => { streamCount: data.streams?.length || 0 })) }); - + const sortedEntries = filteredEntries.sort(([addonIdA], [addonIdB]) => { - // Sort by response order (actual order addons responded) - const indexA = addonResponseOrder.indexOf(addonIdA); - const indexB = addonResponseOrder.indexOf(addonIdB); - - // If both are in response order, sort by response order - if (indexA !== -1 && indexB !== -1) { - return indexA - indexB; - } - - // If only one is in response order, prioritize it - if (indexA !== -1) return -1; - if (indexB !== -1) return 1; - - // If neither is in response order, maintain original order - return 0; - }); + // Sort by response order (actual order addons responded) + const indexA = addonResponseOrder.indexOf(addonIdA); + const indexB = addonResponseOrder.indexOf(addonIdB); + + // If both are in response order, sort by response order + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } + + // If only one is in response order, prioritize it + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + + // If neither is in response order, maintain original order + return 0; + }); // Check if we should group all streams under one section if (settings.streamDisplayMode === 'grouped') { @@ -1436,7 +1433,7 @@ export const StreamsScreen = () => { data: combinedStreams, isEmptyDueToQualityFilter: false }]; - + console.log('πŸ” [StreamsScreen] Grouped mode result:', { resultCount: result.length, combinedStreamsCount: combinedStreams.length, @@ -1444,7 +1441,7 @@ export const StreamsScreen = () => { pluginStreamsCount: pluginStreams.length, totalOriginalCount }); - + return result; } else { // Use separate sections for each provider (current behavior) @@ -1554,7 +1551,7 @@ export const StreamsScreen = () => { data: processedStreams, isEmptyDueToQualityFilter: false }; - + console.log('πŸ” [StreamsScreen] Individual mode result:', { addonId, addonName, @@ -1562,12 +1559,12 @@ export const StreamsScreen = () => { originalCount, isInstalledAddon }); - + return result; }).filter(Boolean); // Filter out null values } }, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, addonResponseOrder, settings.streamSortMode, selectedEpisode, metadata]); - + // Debug log for sections result React.useEffect(() => { console.log('πŸ” [StreamsScreen] Final sections:', { @@ -1611,13 +1608,13 @@ export const StreamsScreen = () => { // Effective rating for hero (series) - prioritize IMDb, fallback to TMDB const effectiveEpisodeVote = useMemo(() => { if (!currentEpisode) return 0; - + // Try IMDb rating first const imdbRating = getIMDbRating(currentEpisode.season_number, currentEpisode.episode_number); if (imdbRating !== null) { return imdbRating; } - + // Fallback to TMDB const v = (tmdbEpisodeOverride?.vote_average ?? currentEpisode.vote_average) || 0; return typeof v === 'number' ? v : Number(v) || 0; @@ -1646,14 +1643,14 @@ export const StreamsScreen = () => { return bannerImage; } } - + // For movies: prioritize bannerImage if (type === 'movie') { if (bannerImage) { return bannerImage; } } - + // For other types or when no specific image available return bannerImage || episodeImage; }, [type, selectedEpisode, episodeImage, bannerImage]); @@ -1664,7 +1661,7 @@ export const StreamsScreen = () => { if (!settings.enableStreamsBackdrop) { return null; } - + if (type === 'series' || (type === 'other' && selectedEpisode)) { // Only use episodeImage - don't fallback to bannerImage // This ensures we get episode-specific colors, not show-wide colors @@ -1687,7 +1684,7 @@ export const StreamsScreen = () => { } // Deduplicate and prefetch Array.from(new Set(urls)).forEach(u => { - RNImage.prefetch(u).catch(() => {}); + RNImage.prefetch(u).catch(() => { }); }); }, [episodeImage, bannerImage, metadata]); @@ -1697,10 +1694,10 @@ export const StreamsScreen = () => { if (settings.enableStreamsBackdrop) { return ['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.6)', 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.95)']; } - + // When backdrop is disabled, use theme background gradient const themeBg = colors.darkBackground; - + // Handle hex color format (e.g., #1a1a1a) if (themeBg.startsWith('#')) { const r = parseInt(themeBg.substr(1, 2), 16); @@ -1714,7 +1711,7 @@ export const StreamsScreen = () => { `rgba(${r},${g},${b},0.95)`, ]; } - + // Handle rgb color format (e.g., rgb(26, 26, 26)) const rgbMatch = themeBg.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (rgbMatch) { @@ -1727,17 +1724,17 @@ export const StreamsScreen = () => { `rgba(${r},${g},${b},0.95)`, ]; } - + if (!baseColor || baseColor === '#1a1a1a') { // Fallback to black gradient with stronger bottom edge return ['rgba(0,0,0,0)', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,0.6)', 'rgba(0,0,0,0.85)', 'rgba(0,0,0,0.95)']; } - + // Convert hex to RGB const r = parseInt(baseColor.substr(1, 2), 16); const g = parseInt(baseColor.substr(3, 2), 16); const b = parseInt(baseColor.substr(5, 2), 16); - + // Create gradient stops with much stronger opacity at bottom return [ `rgba(${r},${g},${b},0)`, @@ -1748,8 +1745,8 @@ export const StreamsScreen = () => { ]; }, [settings.enableStreamsBackdrop, colors.darkBackground]); - const gradientColors = useMemo(() => - createGradientColors(dominantColor), + const gradientColors = useMemo(() => + createGradientColors(dominantColor), [dominantColor, createGradientColors] ); @@ -1757,7 +1754,7 @@ export const StreamsScreen = () => { const streams = metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? episodeStreams : groupedStreams; // Determine extended loading phases - const streamsEmpty = Object.keys(streams).length === 0 || + const streamsEmpty = Object.keys(streams).length === 0 || Object.values(streams).every(provider => !provider.streams || provider.streams.length === 0); const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0; const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000); @@ -1818,266 +1815,266 @@ export const StreamsScreen = () => { return ( - - - - - {Platform.OS !== 'ios' && ( - - - - - {metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? 'Back to Episodes' : 'Back to Info'} - - - - )} - - {isTablet ? ( - + - ) : ( - // PHONE LAYOUT (existing structure) - <> - {/* Full Screen Background for Mobile */} - {settings.enableStreamsBackdrop ? ( - - {mobileBackdropSource ? ( - - ) : ( - - )} - {Platform.OS === 'android' && AndroidBlurView ? ( - - ) : ( - - )} - {/* Dark overlay to reduce brightness */} - {Platform.OS === 'ios' && ( - - )} - - ) : ( - - )} - {type === 'movie' && metadata && ( - - - {metadata.logo && !movieLogoError ? ( - setMovieLogoError(true)} - /> - ) : ( - - {metadata.name} - - )} - - - )} -{currentEpisode && ( - + {Platform.OS !== 'ios' && ( + + + + + {metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? 'Back to Episodes' : 'Back to Info'} + + + + )} + + {isTablet ? ( + + ) : ( + // PHONE LAYOUT (existing structure) + <> + {/* Full Screen Background for Mobile */} + {settings.enableStreamsBackdrop ? ( - + {mobileBackdropSource ? ( - - - {currentEpisode ? ( - - - {currentEpisode.episodeString} - - - {currentEpisode.name} - - {!!currentEpisode.overview && ( - - - {currentEpisode.overview} - - - )} - - - {tmdbService.formatAirDate(currentEpisode.air_date)} - - {effectiveEpisodeVote > 0 && ( - - {hasIMDbRating ? ( - <> - - - {effectiveEpisodeVote.toFixed(1)} - - - ) : ( - <> - - - {effectiveEpisodeVote.toFixed(1)} - - - )} - - )} - {!!effectiveEpisodeRuntime && ( - - - - {effectiveEpisodeRuntime >= 60 - ? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m` - : `${effectiveEpisodeRuntime}m`} - - - )} - - - ) : ( - // Placeholder to reserve space and avoid layout shift while loading - - )} - - - + ) : ( + + )} + {Platform.OS === 'android' && AndroidBlurView ? ( + + ) : ( + + )} + {/* Dark overlay to reduce brightness */} + {Platform.OS === 'ios' && ( + + )} - - )} + ) : ( + + )} - {/* Gradient overlay to blend hero section with streams container */} - {metadata?.videos && metadata.videos.length > 1 && selectedEpisode && ( - - - - )} - - - - {!streamsEmpty && ( - - )} - - - {/* Active Scrapers Status */} - {activeFetchingScrapers.length > 0 && ( - - Fetching from: - - {activeFetchingScrapers.map((scraperName, index) => ( - - ))} + {type === 'movie' && metadata && ( + + + {metadata.logo && !movieLogoError ? ( + setMovieLogoError(true)} + /> + ) : ( + + {metadata.name} + + )} )} - {/* Update the streams/loading state display logic */} - { showNoSourcesError ? ( - + + + + + + {currentEpisode ? ( + + + {currentEpisode.episodeString} + + + {currentEpisode.name} + + {!!currentEpisode.overview && ( + + + {currentEpisode.overview} + + + )} + + + {tmdbService.formatAirDate(currentEpisode.air_date)} + + {effectiveEpisodeVote > 0 && ( + + {hasIMDbRating ? ( + <> + + + {effectiveEpisodeVote.toFixed(1)} + + + ) : ( + <> + + + {effectiveEpisodeVote.toFixed(1)} + + + )} + + )} + {!!effectiveEpisodeRuntime && ( + + + + {effectiveEpisodeRuntime >= 60 + ? `${Math.floor(effectiveEpisodeRuntime / 60)}h ${effectiveEpisodeRuntime % 60}m` + : `${effectiveEpisodeRuntime}m`} + + + )} + + + ) : ( + // Placeholder to reserve space and avoid layout shift while loading + + )} + + + + + + )} + + {/* Gradient overlay to blend hero section with streams container */} + {metadata?.videos && metadata.videos.length > 1 && selectedEpisode && ( + + + + )} + + + + {!streamsEmpty && ( + + )} + + + {/* Active Scrapers Status */} + {activeFetchingScrapers.length > 0 && ( + + Fetching from: + + {activeFetchingScrapers.map((scraperName, index) => ( + + ))} + + + )} + + {/* Update the streams/loading state display logic */} + {showNoSourcesError ? ( + @@ -2092,140 +2089,140 @@ export const StreamsScreen = () => { Add Sources - ) : streamsEmpty ? ( - showInitialLoading ? ( - - - - {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} - - - ) : showStillFetching ? ( - - - Still fetching streams… - - ) : ( - // No streams and not loading = no streams available - - - No streams available - - ) - ) : ( - // Show streams immediately when available, even if still loading others - - {/* Show autoplay loading overlay if waiting for autoplay */} - {isAutoplayWaiting && !autoplayTriggered && ( - - - - Starting best stream... - + + + {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} + - )} - - - {sections.filter(Boolean).map((section, sectionIndex) => ( - - {/* Section Header */} - {renderSectionHeader({ section: section! })} - - {/* Stream Cards using FlatList */} - {section!.data && section!.data.length > 0 ? ( - { - if (item && item.url) { - return `${item.url}-${sectionIndex}-${index}`; - } - return `empty-${sectionIndex}-${index}`; - }} - renderItem={({ item, index }) => ( - - handleStreamPress(item)} - index={index} - isLoading={false} - statusMessage={undefined} - theme={currentTheme} - showLogos={settings.showScraperLogos} - scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null} - showAlert={(t, m) => openAlert(t, m)} - parentTitle={metadata?.name} - parentType={type as 'movie' | 'series'} - parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined} - parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined} - parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined} - parentPosterUrl={episodeImage || metadata?.poster || undefined} - providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))} - parentId={id} - parentImdbId={imdbId || undefined} - /> - - )} - scrollEnabled={false} - initialNumToRender={6} - maxToRenderPerBatch={2} - windowSize={3} - removeClippedSubviews={true} - showsVerticalScrollIndicator={false} - getItemLayout={(data, index) => ({ - length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom) - offset: 78 * index, - index, - })} - /> - ) : null} - - ))} - - {/* Footer Loading */} - {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && ( - - - Loading more sources... + ) : showStillFetching ? ( + + + Still fetching streams… + + ) : ( + // No streams and not loading = no streams available + + + No streams available + + ) + ) : ( + // Show streams immediately when available, even if still loading others + + {/* Show autoplay loading overlay if waiting for autoplay */} + {isAutoplayWaiting && !autoplayTriggered && ( + + + + Starting best stream... + )} - - - )} - - - )} - setAlertVisible(false)} - /> - + + + {sections.filter(Boolean).map((section, sectionIndex) => ( + + {/* Section Header */} + {renderSectionHeader({ section: section! })} + + {/* Stream Cards using FlatList */} + {section!.data && section!.data.length > 0 ? ( + { + if (item && item.url) { + return `${item.url}-${sectionIndex}-${index}`; + } + return `empty-${sectionIndex}-${index}`; + }} + renderItem={({ item, index }) => ( + + handleStreamPress(item)} + index={index} + isLoading={false} + statusMessage={undefined} + theme={currentTheme} + showLogos={settings.showScraperLogos} + scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogoCache.get((item.addonId || (item as any).addon) as string) || null : null} + showAlert={(t, m) => openAlert(t, m)} + parentTitle={metadata?.name} + parentType={type as 'movie' | 'series'} + parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined} + parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined} + parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined} + parentPosterUrl={episodeImage || metadata?.poster || undefined} + providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))} + parentId={id} + parentImdbId={imdbId || undefined} + /> + + )} + scrollEnabled={false} + initialNumToRender={6} + maxToRenderPerBatch={2} + windowSize={3} + removeClippedSubviews={true} + showsVerticalScrollIndicator={false} + getItemLayout={(data, index) => ({ + length: 78, // Approximate height of StreamCard (68 minHeight + 10 marginBottom) + offset: 78 * index, + index, + })} + /> + ) : null} + + ))} + + {/* Footer Loading */} + {(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && ( + + + Loading more sources... + + )} + + + )} + + + )} + setAlertVisible(false)} + /> + ); };