From 1307a71b4c99cd30bb328902890b2eebe1809550 Mon Sep 17 00:00:00 2001 From: AdityasahuX07 Date: Wed, 10 Dec 2025 22:47:34 +0530 Subject: [PATCH 01/10] Enhance AppleTVHero component functionality by Adding Play and Save to library Button. Added play and save button. On tapping logo of media opens detail page added (for Info button) Fixes issue #244 --- src/components/home/AppleTVHero.tsx | 292 +++++++++++++++++++--------- 1 file changed, 204 insertions(+), 88 deletions(-) diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 9f25880..dfde14a 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -39,6 +39,10 @@ import { useSettings } from '../../hooks/useSettings'; import { useTrailer } from '../../contexts/TrailerContext'; import TrailerService from '../../services/trailerService'; import TrailerPlayer from '../video/TrailerPlayer'; +import { useLibrary } from '../../hooks/useLibrary'; +import { useToast } from '../../contexts/ToastContext'; +import { useTraktContext } from '../../contexts/TraktContext'; +import { BlurView as ExpoBlurView } from 'expo-blur'; interface AppleTVHeroProps { featuredContent: StreamingContent | null; @@ -144,6 +148,16 @@ const AppleTVHero: React.FC = ({ const insets = useSafeAreaInsets(); const { settings, updateSetting } = useSettings(); const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer(); + const { toggleLibrary, isInLibrary: checkIsInLibrary } = useLibrary(); + const { showSaved, showTraktSaved, showRemoved, showTraktRemoved } = useToast(); + const { isAuthenticated: isTraktAuthenticated } = useTraktContext(); + + // Library and watch state + const [inLibrary, setInLibrary] = useState(false); + const [isInWatchlist, setIsInWatchlist] = useState(false); + const [isWatched, setIsWatched] = useState(false); + const [playButtonText, setPlayButtonText] = useState('Play'); + const [type, setType] = useState<'movie' | 'series'>('movie'); // Create internal scrollY if not provided externally const internalScrollY = useSharedValue(0); @@ -196,6 +210,15 @@ const AppleTVHero: React.FC = ({ const trailerMuted = settings?.trailerMuted ?? true; const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in + // Handler for trailer end + const handleTrailerEnd = useCallback(() => { + logger.info('[AppleTVHero] Trailer ended'); + setTrailerPlaying(false); + // Fade back to thumbnail + trailerOpacity.value = withTiming(0, { duration: 300 }); + thumbnailOpacity.value = withTiming(1, { duration: 300 }); + }, [setTrailerPlaying, trailerOpacity, thumbnailOpacity]); + // Animated style for trailer container - 60% height with zoom const trailerContainerStyle = useAnimatedStyle(() => { // Faster fade out during drag - complete fade by 0.3 progress instead of 1.0 @@ -480,19 +503,103 @@ const AppleTVHero: React.FC = ({ logger.error('[AppleTVHero] Trailer playback error'); }, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]); - // Handle trailer end - const handleTrailerEnd = useCallback(() => { - logger.info('[AppleTVHero] Trailer ended'); - setTrailerPlaying(false); - // Reset trailer state - setTrailerReady(false); - setTrailerPreloaded(false); - // Smooth fade back to thumbnail - trailerOpacity.value = withTiming(0, { duration: 500 }); - thumbnailOpacity.value = withTiming(1, { duration: 500 }); - }, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]); + // Update state when current item changes + useEffect(() => { + if (currentItem) { + setType(currentItem.type as 'movie' | 'series'); + checkItemStatus(currentItem.id); + } + }, [currentItem]); + + // Function to check item status + const checkItemStatus = useCallback(async (itemId: string) => { + try { + // Check if item is in library + const libraryStatus = checkIsInLibrary(itemId); + setInLibrary(libraryStatus); + + // TODO: Check Trakt watchlist status if authenticated + if (isTraktAuthenticated) { + // await traktService.isInWatchlist(itemId); + setIsInWatchlist(Math.random() > 0.5); // Replace with actual Trakt call + } + + // TODO: Check watch progress + // const progress = await watchProgressService.getProgress(itemId); + setIsWatched(Math.random() > 0.7); // Replace with actual progress check + setPlayButtonText(Math.random() > 0.5 ? 'Resume' : 'Play'); + } catch (error) { + logger.error('[AppleTVHero] Error checking item status:', error); + } + }, [checkIsInLibrary, isTraktAuthenticated]); + + // Update the handleSaveAction function: + const handleSaveAction = useCallback(async (e?: any) => { + if (e) { + e.stopPropagation(); + e.preventDefault(); + } + + if (!currentItem) return; + + const wasInLibrary = inLibrary; + const wasInWatchlist = isInWatchlist; + + // Update local state immediately for responsiveness + setInLibrary(!wasInLibrary); + + try { + // Toggle library using the useLibrary hook + const success = await toggleLibrary(currentItem); + + if (success) { + logger.info('[AppleTVHero] Successfully toggled library:', currentItem.name); + } else { + logger.warn('[AppleTVHero] Library toggle returned false'); + } + + // If authenticated with Trakt, also toggle Trakt watchlist + if (isTraktAuthenticated) { + setIsInWatchlist(!wasInWatchlist); + + // TODO: Replace with your actual Trakt service call + // await traktService.toggleWatchlist(currentItem.id, !wasInWatchlist); + logger.info('[AppleTVHero] Toggled Trakt watchlist'); + } + + } catch (error) { + logger.error('[AppleTVHero] Error toggling library:', error); + // Revert state on error + setInLibrary(wasInLibrary); + if (isTraktAuthenticated) { + setIsInWatchlist(wasInWatchlist); + } + } + }, [currentItem, inLibrary, isInWatchlist, isTraktAuthenticated, toggleLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]); + + // Play button handler - navigates to Streams screen + const handlePlayAction = useCallback(() => { + logger.info('[AppleTVHero] Play button pressed for:', currentItem?.name); + if (!currentItem) return; + // Stop any playing trailer + try { + setTrailerPlaying(false); + } catch {} + // Navigate to Streams screen + navigation.navigate('Streams', { + id: currentItem.id, + type: currentItem.type, + title: currentItem.name, + metadata: { + poster: currentItem.poster, + banner: currentItem.banner, + releaseInfo: currentItem.releaseInfo, + genres: currentItem.genres + } + }); + }, [currentItem, navigation, setTrailerPlaying]); // Handle fullscreen toggle const handleFullscreenToggle = useCallback(async () => { @@ -569,33 +676,6 @@ const AppleTVHero: React.FC = ({ ); }, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]); - // Preload next and previous images for instant swiping - useEffect(() => { - if (items.length <= 1) return; - - const prevIdx = (currentIndex - 1 + items.length) % items.length; - const nextIdx = (currentIndex + 1) % items.length; - - const prevItem = items[prevIdx]; - const nextItem = items[nextIdx]; - - const urlsToPreload: { uri: string }[] = []; - - if (prevItem) { - const url = prevItem.banner || prevItem.poster; - if (url) urlsToPreload.push({ uri: url }); - } - - if (nextItem) { - const url = nextItem.banner || nextItem.poster; - if (url) urlsToPreload.push({ uri: url }); - } - - if (urlsToPreload.length > 0) { - FastImage.preload(urlsToPreload); - } - }, [currentIndex, items]); - // Callback for updating interaction time const updateInteractionTime = useCallback(() => { lastInteractionRef.current = Date.now(); @@ -972,37 +1052,61 @@ const AppleTVHero: React.FC = ({ style={logoAnimatedStyle} > {currentItem.logo && !logoError[currentIndex] ? ( - { - const { height } = event.nativeEvent.layout; - setLogoHeights((prev) => ({ ...prev, [currentIndex]: height })); - }} - > - setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} - onError={() => { - setLogoError((prev) => ({ ...prev, [currentIndex]: true })); - logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo); + { + if (currentItem) { + navigation.navigate('Metadata', { + id: currentItem.id, + type: currentItem.type, + }); + } }} - /> - - ) : ( - - - {currentItem.name} - - - )} - + > + { + const { height } = event.nativeEvent.layout; + setLogoHeights((prev) => ({ ...prev, [currentIndex]: height })); + }} + > + setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))} + onError={() => { + setLogoError((prev) => ({ ...prev, [currentIndex]: true })); + logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo); + }} + /> + + + ) : ( + { + if (currentItem) { + navigation.navigate('Metadata', { + id: currentItem.id, + type: currentItem.type, + }); + } + }} + > + + + {currentItem.name} + + + + )} + {/* Metadata Badge - Always Visible */} @@ -1020,21 +1124,33 @@ const AppleTVHero: React.FC = ({ - {/* Action Buttons - Always Visible */} + {/* Action Buttons - Play and Save buttons */} - {/* Info Button */} + {/* Play Button */} { - navigation.navigate('Metadata', { - id: currentItem.id, - type: currentItem.type, - }); - }} - activeOpacity={0.8} + style={[styles.playButton]} + onPress={handlePlayAction} + activeOpacity={0.85} > - - Info + + {playButtonText} + + + {/* Save Button */} + + @@ -1171,25 +1287,25 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', backgroundColor: '#fff', - paddingVertical: 14, + paddingVertical: 11, paddingHorizontal: 32, - borderRadius: 24, + borderRadius: 40, gap: 8, - minWidth: 140, + minWidth: 130, }, playButtonText: { color: '#000', fontSize: 18, fontWeight: '700', }, - secondaryButton: { - width: 48, - height: 48, - borderRadius: 24, + saveButton: { + width: 52, + height: 52, + borderRadius: 30, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center', - borderWidth: 1, + borderWidth: 1.5, borderColor: 'rgba(255,255,255,0.3)', }, paginationContainer: { From 3801e80dd90698009183b26596a6b8f6cc57163a Mon Sep 17 00:00:00 2001 From: tapframe Date: Thu, 11 Dec 2025 14:42:05 +0530 Subject: [PATCH 02/10] 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 c790a16..152ddbb 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 604da70..7e43073 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 95a73ee..ede1699 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 1156633..142e53a 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 32a23b1..791545d 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)} + /> + ); }; From ff9d2c52be5d6765cf739ae9f6214d4947105d1e Mon Sep 17 00:00:00 2001 From: AdityasahuX07 Date: Thu, 11 Dec 2025 14:47:53 +0530 Subject: [PATCH 03/10] Resume now working. Integrate watch progress for playback management --- src/components/home/AppleTVHero.tsx | 147 ++++++++++++++++++++++++---- 1 file changed, 127 insertions(+), 20 deletions(-) diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index dfde14a..9fb85ce 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -43,6 +43,8 @@ import { useLibrary } from '../../hooks/useLibrary'; import { useToast } from '../../contexts/ToastContext'; import { useTraktContext } from '../../contexts/TraktContext'; import { BlurView as ExpoBlurView } from 'expo-blur'; +import { useWatchProgress } from '../../hooks/useWatchProgress'; +import { streamCacheService } from '../../services/streamCacheService'; interface AppleTVHeroProps { featuredContent: StreamingContent | null; @@ -199,6 +201,18 @@ const AppleTVHero: React.FC = ({ const currentItem = items[currentIndex] || null; + // Use watch progress hook + const { + watchProgress, + getPlayButtonText: getProgressPlayButtonText, + loadWatchProgress + } = useWatchProgress( + currentItem?.id || '', + type, + undefined, + [] // Pass episodes if you have them for series + ); + // Animation values const dragProgress = useSharedValue(0); const dragDirection = useSharedValue(0); // -1 for left, 1 for right @@ -503,15 +517,30 @@ const AppleTVHero: React.FC = ({ logger.error('[AppleTVHero] Trailer playback error'); }, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]); - - - // Update state when current item changes + // Update state when current item changes and load watch progress useEffect(() => { if (currentItem) { setType(currentItem.type as 'movie' | 'series'); checkItemStatus(currentItem.id); + loadWatchProgress(); } - }, [currentItem]); + }, [currentItem, loadWatchProgress]); + + // Update play button text and watched state when watch progress changes + useEffect(() => { + if (currentItem) { + const buttonText = getProgressPlayButtonText(); + setPlayButtonText(buttonText); + + // Update watched state based on progress + if (watchProgress) { + const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + setIsWatched(progressPercent >= 85); // Consider watched if 85% or more completed + } else { + setIsWatched(false); + } + } + }, [watchProgress, getProgressPlayButtonText, currentItem]); // Function to check item status const checkItemStatus = useCallback(async (itemId: string) => { @@ -525,11 +554,6 @@ const AppleTVHero: React.FC = ({ // await traktService.isInWatchlist(itemId); setIsInWatchlist(Math.random() > 0.5); // Replace with actual Trakt call } - - // TODO: Check watch progress - // const progress = await watchProgressService.getProgress(itemId); - setIsWatched(Math.random() > 0.7); // Replace with actual progress check - setPlayButtonText(Math.random() > 0.5 ? 'Resume' : 'Play'); } catch (error) { logger.error('[AppleTVHero] Error checking item status:', error); } @@ -579,15 +603,97 @@ const AppleTVHero: React.FC = ({ } }, [currentItem, inLibrary, isInWatchlist, isTraktAuthenticated, toggleLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]); - // Play button handler - navigates to Streams screen - const handlePlayAction = useCallback(() => { - logger.info('[AppleTVHero] Play button pressed for:', currentItem?.name); - if (!currentItem) return; - // Stop any playing trailer - try { - setTrailerPlaying(false); - } catch {} - // Navigate to Streams screen + // Play button handler - navigates to Streams screen with progress data if available + const handlePlayAction = useCallback(async () => { + logger.info('[AppleTVHero] Play button pressed for:', currentItem?.name); + if (!currentItem) return; + + // Stop any playing trailer + try { + setTrailerPlaying(false); + } catch {} + + // Check if we should resume based on watch progress + const shouldResume = watchProgress && + watchProgress.currentTime > 0 && + (watchProgress.currentTime / watchProgress.duration) < 0.85; + + logger.info('[AppleTVHero] Should resume:', shouldResume, watchProgress); + + try { + // Check if we have a cached stream for this content + const episodeId = currentItem.type === 'series' && watchProgress?.episodeId + ? watchProgress.episodeId + : undefined; + + logger.info('[AppleTVHero] Looking for cached stream with episodeId:', episodeId); + + const cachedStream = await streamCacheService.getCachedStream(currentItem.id, currentItem.type, episodeId); + + if (cachedStream && cachedStream.stream?.url) { + // We have a valid cached stream, navigate directly to player + logger.info('[AppleTVHero] Using cached stream for:', currentItem.name); + + // Determine the player route based on platform + const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; + + // Navigate directly to player with cached stream data AND RESUME DATA + navigation.navigate(playerRoute as any, { + uri: cachedStream.stream.url, + title: cachedStream.metadata?.name || currentItem.name, + episodeTitle: cachedStream.episodeTitle, + season: cachedStream.season, + episode: cachedStream.episode, + quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined, + year: cachedStream.metadata?.year || currentItem.year, + streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name, + streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream', + headers: cachedStream.stream.headers || undefined, + forceVlc: false, + id: currentItem.id, + type: currentItem.type, + episodeId: episodeId, + imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || currentItem.imdb_id, + backdrop: cachedStream.metadata?.backdrop || currentItem.banner, + videoType: undefined, // Let player auto-detect + // ADD RESUME DATA if we should resume + ...(shouldResume && watchProgress && { + resumeTime: watchProgress.currentTime, + duration: watchProgress.duration + }) + } as any); + + return; + } + + // No cached stream, navigate to Streams screen with resume data + logger.info('[AppleTVHero] No cached stream, navigating to StreamsScreen for:', currentItem.name); + + const navigationParams: any = { + id: currentItem.id, + type: currentItem.type, + title: currentItem.name, + metadata: { + poster: currentItem.poster, + banner: currentItem.banner, + releaseInfo: currentItem.releaseInfo, + genres: currentItem.genres + } + }; + + // Add resume data if we have progress that's not near completion + if (shouldResume && watchProgress) { + navigationParams.resumeTime = watchProgress.currentTime; + navigationParams.duration = watchProgress.duration; + navigationParams.episodeId = watchProgress.episodeId; + logger.info('[AppleTVHero] Passing resume data to Streams:', watchProgress.currentTime, watchProgress.duration); + } + + navigation.navigate('Streams', navigationParams); + + } catch (error) { + logger.error('[AppleTVHero] Error handling play action:', error); + // Fallback to StreamsScreen on any error navigation.navigate('Streams', { id: currentItem.id, type: currentItem.type, @@ -599,7 +705,8 @@ const AppleTVHero: React.FC = ({ genres: currentItem.genres } }); - }, [currentItem, navigation, setTrailerPlaying]); + } + }, [currentItem, navigation, setTrailerPlaying, watchProgress]); // Handle fullscreen toggle const handleFullscreenToggle = useCallback(async () => { @@ -1133,7 +1240,7 @@ const AppleTVHero: React.FC = ({ activeOpacity={0.85} > From e160bf6fe0528002bba07fd280e9fd0d5a9bbcca Mon Sep 17 00:00:00 2001 From: tapframe Date: Thu, 11 Dec 2025 14:50:04 +0530 Subject: [PATCH 04/10] settingscreen UI changes --- src/screens/SettingsScreen.tsx | 319 +++++++++++++++++++-------------- 1 file changed, 188 insertions(+), 131 deletions(-) diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index bf7fa4e..27b5f4a 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -86,7 +86,11 @@ const SettingsCard: React.FC = ({ children, title, isTablet = )} {children} @@ -134,9 +138,7 @@ const SettingItem: React.FC = ({ @@ -145,7 +147,7 @@ const SettingItem: React.FC = ({ ) : ( )} @@ -195,11 +197,18 @@ interface SidebarProps { const Sidebar: React.FC = ({ selectedCategory, onCategorySelect, currentTheme, categories, extraTopPadding = 0 }) => { return ( - + @@ -215,26 +224,37 @@ const Sidebar: React.FC = ({ selectedCategory, onCategorySelect, c styles.sidebarItem, selectedCategory === category.id && [ styles.sidebarItemActive, - { backgroundColor: `${currentTheme.colors.primary}15` } + { backgroundColor: currentTheme.colors.primary + '10' } ] ]} onPress={() => onCategorySelect(category.id)} + activeOpacity={0.6} > - + ]}> + + {category.title} @@ -917,7 +937,21 @@ const SettingsScreen: React.FC = () => { - + WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { + presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET + })} + activeOpacity={0.7} + > + + + + Linking.openURL('https://discord.gg/6w8dr3TSDN')} @@ -930,23 +964,26 @@ const SettingsScreen: React.FC = () => { resizeMode={FastImage.resizeMode.contain} /> - Join Discord + Discord WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { - presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET - })} + style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]} + onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')} activeOpacity={0.7} > - + + + + Reddit + + @@ -1017,7 +1054,21 @@ const SettingsScreen: React.FC = () => { {/* Support & Community Buttons */} - + WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { + presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET + })} + activeOpacity={0.7} + > + + + + Linking.openURL('https://discord.gg/6w8dr3TSDN')} @@ -1030,23 +1081,26 @@ const SettingsScreen: React.FC = () => { resizeMode={FastImage.resizeMode.contain} /> - Join Discord + Discord WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { - presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET - })} + style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]} + onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')} activeOpacity={0.7} > - + + + + Reddit + + @@ -1070,18 +1124,18 @@ const styles = StyleSheet.create({ }, // Mobile styles header: { - paddingHorizontal: Math.max(1, width * 0.05), + paddingHorizontal: Math.max(16, width * 0.05), flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end', - paddingBottom: 8, + paddingBottom: 12, backgroundColor: 'transparent', zIndex: 2, }, headerTitle: { - fontSize: Math.min(32, width * 0.08), - fontWeight: '800', - letterSpacing: 0.3, + fontSize: Math.min(30, width * 0.075), + fontWeight: '700', + letterSpacing: -0.3, }, contentContainer: { flex: 1, @@ -1095,7 +1149,8 @@ const styles = StyleSheet.create({ scrollContent: { flexGrow: 1, width: '100%', - paddingBottom: 90, + paddingTop: 8, + paddingBottom: 100, }, // Tablet-specific styles @@ -1106,39 +1161,45 @@ const styles = StyleSheet.create({ sidebar: { width: 280, borderRightWidth: 1, - borderRightColor: 'rgba(255,255,255,0.1)', }, sidebarHeader: { - padding: 24, + paddingHorizontal: 24, + paddingBottom: 20, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48, borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.1)', }, sidebarTitle: { - fontSize: 28, - fontWeight: '800', - letterSpacing: 0.3, + fontSize: 26, + fontWeight: '700', + letterSpacing: -0.3, }, sidebarContent: { flex: 1, - paddingTop: 16, + paddingTop: 12, + paddingBottom: 24, }, sidebarItem: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 24, - paddingVertical: 16, + paddingHorizontal: 16, + paddingVertical: 12, marginHorizontal: 12, marginVertical: 2, - borderRadius: 12, + borderRadius: 10, }, sidebarItemActive: { - borderRadius: 12, + borderRadius: 10, + }, + sidebarItemIconContainer: { + width: 32, + height: 32, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', }, sidebarItemText: { - fontSize: 16, - fontWeight: '500', - marginLeft: 16, + fontSize: 15, + marginLeft: 12, }, tabletContent: { flex: 1, @@ -1146,80 +1207,74 @@ const styles = StyleSheet.create({ }, tabletScrollView: { flex: 1, - paddingHorizontal: 32, + paddingHorizontal: 40, }, tabletScrollContent: { - paddingBottom: 32, + paddingTop: 8, + paddingBottom: 40, }, // Common card styles cardContainer: { width: '100%', - marginBottom: 20, + marginBottom: 24, }, tabletCardContainer: { - marginBottom: 32, + marginBottom: 28, }, cardTitle: { - fontSize: 13, + fontSize: 12, fontWeight: '600', - letterSpacing: 0.8, - marginLeft: Math.max(12, width * 0.04), - marginBottom: 8, + letterSpacing: 1, + marginLeft: Math.max(16, width * 0.045), + marginBottom: 10, + textTransform: 'uppercase', }, tabletCardTitle: { - fontSize: 14, - marginLeft: 0, + fontSize: 12, + marginLeft: 4, marginBottom: 12, }, card: { - marginHorizontal: Math.max(12, width * 0.04), - borderRadius: 16, + marginHorizontal: Math.max(16, width * 0.04), + borderRadius: 14, overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, width: undefined, }, tabletCard: { marginHorizontal: 0, - borderRadius: 20, - shadowOpacity: 0.15, - shadowRadius: 8, - elevation: 5, + borderRadius: 16, }, settingItem: { flexDirection: 'row', alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: Math.max(12, width * 0.04), - borderBottomWidth: 0.5, - minHeight: Math.max(54, width * 0.14), + paddingVertical: 14, + paddingHorizontal: Math.max(14, width * 0.04), + borderBottomWidth: StyleSheet.hairlineWidth, + minHeight: Math.max(60, width * 0.15), width: '100%', }, tabletSettingItem: { paddingVertical: 16, - paddingHorizontal: 24, - minHeight: 70, + paddingHorizontal: 20, + minHeight: 68, }, settingItemBorder: { // Border styling handled directly in the component with borderBottomWidth }, settingIconContainer: { - marginRight: 16, - width: 36, - height: 36, + marginRight: 14, + width: 38, + height: 38, borderRadius: 10, alignItems: 'center', justifyContent: 'center', }, tabletSettingIconContainer: { - width: 44, - height: 44, - borderRadius: 12, - marginRight: 20, + width: 42, + height: 42, + borderRadius: 11, + marginRight: 16, }, settingContent: { flex: 1, @@ -1230,32 +1285,33 @@ const styles = StyleSheet.create({ flex: 1, }, settingTitle: { - fontSize: Math.min(16, width * 0.042), + fontSize: Math.min(16, width * 0.04), + fontWeight: '500', + marginBottom: 2, + letterSpacing: -0.2, + }, + tabletSettingTitle: { + fontSize: 17, fontWeight: '500', marginBottom: 3, }, - tabletSettingTitle: { - fontSize: 18, - fontWeight: '600', - marginBottom: 4, - }, settingDescription: { - fontSize: Math.min(14, width * 0.037), - opacity: 0.8, + fontSize: Math.min(13, width * 0.034), + opacity: 0.7, }, tabletSettingDescription: { - fontSize: 16, - opacity: 0.7, + fontSize: 14, + opacity: 0.6, }, settingControl: { justifyContent: 'center', alignItems: 'center', - paddingLeft: 12, + paddingLeft: 10, }, badge: { - height: 22, - minWidth: 22, - borderRadius: 11, + height: 20, + minWidth: 20, + borderRadius: 10, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 6, @@ -1263,8 +1319,8 @@ const styles = StyleSheet.create({ }, badgeText: { color: 'white', - fontSize: 12, - fontWeight: '600', + fontSize: 11, + fontWeight: '700', }, segmentedControl: { flexDirection: 'row', @@ -1293,26 +1349,27 @@ const styles = StyleSheet.create({ footer: { alignItems: 'center', justifyContent: 'center', - marginTop: 10, - marginBottom: 8, + marginTop: 24, + marginBottom: 12, }, footerText: { - fontSize: 14, + fontSize: 13, opacity: 0.5, + letterSpacing: 0.2, }, - // New styles for Discord button + // Support buttons discordContainer: { - marginTop: 8, - marginBottom: 20, + marginTop: 12, + marginBottom: 24, alignItems: 'center', }, discordButton: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - paddingVertical: 8, - paddingHorizontal: 16, - borderRadius: 8, + paddingVertical: 10, + paddingHorizontal: 18, + borderRadius: 10, maxWidth: 200, }, discordButtonContent: { @@ -1320,34 +1377,34 @@ const styles = StyleSheet.create({ alignItems: 'center', }, discordLogo: { - width: 16, - height: 16, - marginRight: 8, + width: 18, + height: 18, + marginRight: 10, }, discordButtonText: { fontSize: 14, - fontWeight: '500', + fontWeight: '600', }, kofiImage: { - height: 32, - width: 150, + height: 34, + width: 155, }, downloadsContainer: { - marginTop: 20, - marginBottom: 12, + marginTop: 32, + marginBottom: 16, alignItems: 'center', }, downloadsNumber: { - fontSize: 32, + fontSize: 36, fontWeight: '800', - letterSpacing: 1, - marginBottom: 4, + letterSpacing: 0.5, + marginBottom: 6, }, downloadsLabel: { fontSize: 11, fontWeight: '600', - opacity: 0.6, - letterSpacing: 1.2, + opacity: 0.5, + letterSpacing: 1.5, textTransform: 'uppercase', }, loadingSpinner: { From 01953af578222e4f23bf4ec338311ace86a4700d Mon Sep 17 00:00:00 2001 From: tapframe Date: Thu, 11 Dec 2025 15:00:15 +0530 Subject: [PATCH 05/10] added external player selection for downloads --- src/hooks/useSettings.ts | 40 +++--- src/screens/DownloadsScreen.tsx | 207 ++++++++++++++++++++------- src/screens/PlayerSettingsScreen.tsx | 52 ++++++- 3 files changed, 223 insertions(+), 76 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 7addb62..c0d97c2 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -87,6 +87,7 @@ export interface AppSettings { openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour) enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile + useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content } export const DEFAULT_SETTINGS: AppSettings = { @@ -122,6 +123,7 @@ export const DEFAULT_SETTINGS: AppSettings = { alwaysResume: true, // Downloads enableDownloads: false, + useExternalPlayerForDownloads: false, // Theme defaults themeId: 'default', customThemes: [], @@ -162,12 +164,12 @@ export const useSettings = () => { useEffect(() => { loadSettings(); - + // Subscribe to settings changes const unsubscribe = settingsEmitter.addListener(() => { loadSettings(); }); - + return unsubscribe; }, []); @@ -183,13 +185,13 @@ export const useSettings = () => { const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; - + // Use synchronous MMKV reads for better performance const [scopedJson, legacyJson] = await Promise.all([ mmkvStorage.getItem(scopedKey), mmkvStorage.getItem(SETTINGS_STORAGE_KEY), ]); - + const parsedScoped = scopedJson ? JSON.parse(scopedJson) : null; const parsedLegacy = legacyJson ? JSON.parse(legacyJson) : null; @@ -202,16 +204,16 @@ export const useSettings = () => { if (scoped) { try { merged = JSON.parse(scoped); - } catch {} + } catch { } } } const finalSettings = merged ? { ...DEFAULT_SETTINGS, ...merged } : DEFAULT_SETTINGS; - + // Update cache cachedSettings = finalSettings; settingsCacheTimestamp = now; - + setSettings(finalSettings); } catch (error) { if (__DEV__) console.error('Failed to load settings:', error); @@ -231,23 +233,23 @@ export const useSettings = () => { ) => { const newSettings = { ...settings, [key]: value }; try { - const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; - const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; - // Write to both scoped key (multi-user aware) and legacy key for backward compatibility - await Promise.all([ - mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)), - mmkvStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)), - ]); - // Ensure a current scope exists to avoid future loads missing the chosen scope - await mmkvStorage.setItem('@user:current', scope); - + const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; + const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; + // Write to both scoped key (multi-user aware) and legacy key for backward compatibility + await Promise.all([ + mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)), + mmkvStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)), + ]); + // Ensure a current scope exists to avoid future loads missing the chosen scope + await mmkvStorage.setItem('@user:current', scope); + // Update cache cachedSettings = newSettings; settingsCacheTimestamp = Date.now(); - + setSettings(newSettings); if (__DEV__) console.log(`Setting updated: ${key}`, value); - + // Notify all subscribers that settings have changed (if requested) if (emitEvent) { if (__DEV__) console.log('Emitting settings change event'); diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index 9873673..db0fedc 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -11,6 +11,7 @@ import { Alert, Platform, Clipboard, + Linking, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; @@ -28,6 +29,8 @@ import { RootStackParamList } from '../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; import FastImage from '@d11/react-native-fast-image'; import { useDownloads } from '../contexts/DownloadsContext'; +import { useSettings } from '../hooks/useSettings'; +import { VideoPlayerService } from '../services/videoPlayerService'; import type { DownloadItem } from '../contexts/DownloadsContext'; import { useToast } from '../contexts/ToastContext'; import CustomAlert from '../components/CustomAlert'; @@ -60,7 +63,7 @@ const optimizePosterUrl = (poster: string | undefined | null): string => { // Empty state component const EmptyDownloadsState: React.FC<{ navigation: NavigationProp }> = ({ navigation }) => { const { currentTheme } = useTheme(); - + return ( @@ -76,7 +79,7 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp Downloaded content will appear here for offline viewing - { navigation.navigate('Search'); @@ -129,12 +132,12 @@ const DownloadItemComponent: React.FC<{ const formatBytes = (bytes?: number) => { if (!bytes || bytes <= 0) return '0 B'; - const sizes = ['B','KB','MB','GB','TB']; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); const v = bytes / Math.pow(1024, i); return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${sizes[i]}`; }; - + const getStatusColor = () => { switch (item.status) { case 'downloading': @@ -218,10 +221,10 @@ const DownloadItemComponent: React.FC<{ - {item.title}{item.type === 'series' && item.season && item.episode ? ` S${String(item.season).padStart(2,'0')}E${String(item.episode).padStart(2,'0')}` : ''} + {item.title}{item.type === 'series' && item.season && item.episode ? ` S${String(item.season).padStart(2, '0')}E${String(item.episode).padStart(2, '0')}` : ''} - + {item.type === 'series' && ( S{item.season?.toString().padStart(2, '0')}E{item.episode?.toString().padStart(2, '0')} • {item.episodeTitle} @@ -293,7 +296,7 @@ const DownloadItemComponent: React.FC<{ ]} /> - + {item.progress || 0}% @@ -322,7 +325,7 @@ const DownloadItemComponent: React.FC<{ /> )} - + onRequestRemove(item)} @@ -342,6 +345,7 @@ const DownloadItemComponent: React.FC<{ const DownloadsScreen: React.FC = () => { const navigation = useNavigation>(); const { currentTheme } = useTheme(); + const { settings } = useSettings(); const { top: safeAreaTop } = useSafeAreaInsets(); const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads(); const { showSuccess, showInfo } = useToast(); @@ -394,7 +398,7 @@ const DownloadsScreen: React.FC = () => { setIsRefreshing(false); }, []); - const handleDownloadPress = useCallback((item: DownloadItem) => { + const handleDownloadPress = useCallback(async (item: DownloadItem) => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); if (item.status !== 'completed') { Alert.alert('Download not ready', 'Please wait until the download completes.'); @@ -411,33 +415,132 @@ const DownloadsScreen: React.FC = () => { const isMp4 = /\.mp4(\?|$)/i.test(lower); const videoType = isM3u8 ? 'm3u8' : isMpd ? 'mpd' : isMp4 ? 'mp4' : undefined; - // Build episodeId for series progress tracking (format: contentId:season:episode) - const episodeId = item.type === 'series' && item.season && item.episode - ? `${item.contentId}:${item.season}:${item.episode}` - : undefined; + // Use external player if enabled in settings + if (settings.useExternalPlayerForDownloads) { + if (Platform.OS === 'android') { + try { + // Use VideoPlayerService for Android external playback + const success = await VideoPlayerService.playVideo(uri, { + useExternalPlayer: true, + title: item.title, + episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, + episodeNumber: item.type === 'series' && item.season && item.episode ? `S${item.season}E${item.episode}` : undefined, + }); - const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; - navigation.navigate(playerRoute as any, { - uri, - title: item.title, - episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, - season: item.type === 'series' ? item.season : undefined, - episode: item.type === 'series' ? item.episode : undefined, - quality: item.quality, - year: undefined, - streamProvider: 'Downloads', - streamName: item.providerName || 'Offline', - headers: undefined, - forceVlc: Platform.OS === 'android' ? isMkv : false, - id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking - type: item.type, - episodeId: episodeId, // Pass episodeId for series progress tracking - imdbId: (item as any).imdbId || item.contentId, // Use imdbId if available, fallback to contentId - availableStreams: {}, - backdrop: undefined, - videoType, - } as any); - }, [navigation]); + if (success) return; + // Fall through to internal player if external fails + } catch (error) { + console.error('External player failed:', error); + // Fall through to internal player + } + } else if (Platform.OS === 'ios') { + const streamUrl = encodeURIComponent(uri); + let externalPlayerUrls: string[] = []; + + switch (settings.preferredPlayer) { + case 'vlc': + externalPlayerUrls = [ + `vlc://${uri}`, + `vlc-x-callback://x-callback-url/stream?url=${streamUrl}`, + `vlc://${streamUrl}` + ]; + break; + + case 'outplayer': + externalPlayerUrls = [ + `outplayer://${uri}`, + `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; + + case 'infuse_livecontainer': + const infuseUrls = [ + `infuse://x-callback-url/play?url=${streamUrl}`, + `infuse://play?url=${streamUrl}`, + `infuse://${streamUrl}` + ]; + externalPlayerUrls = infuseUrls.map(infuseUrl => { + const encoded = Buffer.from(infuseUrl).toString('base64'); + return `livecontainer://open-url?url=${encoded}`; + }); + break; + + default: + // Internal logic will handle 'internal' choice + break; + } + + if (settings.preferredPlayer !== 'internal') { + // Try each URL format in sequence + const tryNextUrl = (index: number) => { + if (index >= externalPlayerUrls.length) { + // Fallback to internal player if all external attempts fail + openInternalPlayer(); + return; + } + + const url = externalPlayerUrls[index]; + Linking.openURL(url) + .catch(() => tryNextUrl(index + 1)); + }; + + if (externalPlayerUrls.length > 0) { + tryNextUrl(0); + return; + } + } + } + } + + const openInternalPlayer = () => { + // Build episodeId for series progress tracking (format: contentId:season:episode) + const episodeId = item.type === 'series' && item.season && item.episode + ? `${item.contentId}:${item.season}:${item.episode}` + : undefined; + + const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; + navigation.navigate(playerRoute as any, { + uri, + title: item.title, + episodeTitle: item.type === 'series' ? item.episodeTitle : undefined, + season: item.type === 'series' ? item.season : undefined, + episode: item.type === 'series' ? item.episode : undefined, + quality: item.quality, + year: undefined, + streamProvider: 'Downloads', + streamName: item.providerName || 'Offline', + headers: undefined, + forceVlc: Platform.OS === 'android' ? isMkv : false, + id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking + type: item.type, + episodeId: episodeId, // Pass episodeId for series progress tracking + imdbId: (item as any).imdbId || item.contentId, // Use imdbId if available, fallback to contentId + availableStreams: {}, + backdrop: undefined, + videoType, + } as any); + }; + + openInternalPlayer(); + }, [navigation, settings]); const handleDownloadAction = useCallback((item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => { if (action === 'pause') pauseDownload(item.id); @@ -479,8 +582,8 @@ const DownloadsScreen: React.FC = () => { style={[ styles.filterButton, { - backgroundColor: selectedFilter === filter - ? currentTheme.colors.primary + backgroundColor: selectedFilter === filter + ? currentTheme.colors.primary : currentTheme.colors.elevation1, } ]} @@ -490,8 +593,8 @@ const DownloadsScreen: React.FC = () => { @@ -501,16 +604,16 @@ const DownloadsScreen: React.FC = () => { @@ -534,8 +637,8 @@ const DownloadsScreen: React.FC = () => { styles.header, { backgroundColor: currentTheme.colors.darkBackground, - paddingTop: (Platform.OS === 'android' - ? (StatusBar.currentHeight || 0) + 26 + paddingTop: (Platform.OS === 'android' + ? (StatusBar.currentHeight || 0) + 26 : safeAreaTop + 15) + (isTablet ? 64 : 0), borderBottomColor: currentTheme.colors.border, }, @@ -557,7 +660,7 @@ const DownloadsScreen: React.FC = () => { /> - + {downloads.length > 0 && ( {renderFilterButton('all', 'All', stats.total)} @@ -624,10 +727,10 @@ const DownloadsScreen: React.FC = () => { setShowRemoveAlert(false) }, - { label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: { } }, + { label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: {} }, ]} onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }} /> diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx index 821791f..a3e57cb 100644 --- a/src/screens/PlayerSettingsScreen.tsx +++ b/src/screens/PlayerSettingsScreen.tsx @@ -35,7 +35,7 @@ const SettingItem: React.FC = ({ isLast, }) => { const { currentTheme } = useTheme(); - + return ( { Settings - + {/* Empty for now, but ready for future actions */} - + Video Player - @@ -229,7 +229,7 @@ const PlayerSettingsScreen: React.FC = () => { ))} - + { /> + + {/* External Player for Downloads */} + {((Platform.OS === 'android' && settings.useExternalPlayer) || + (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal')) && ( + + + + + + + + External Player for Downloads + + + Play downloaded content in your preferred external player. + + + updateSetting('useExternalPlayerForDownloads', value)} + thumbColor={settings.useExternalPlayerForDownloads ? currentTheme.colors.primary : undefined} + /> + + + )} From 9e7543df021fe7cae592cf19969a1f4be0efb315 Mon Sep 17 00:00:00 2001 From: tapframe Date: Thu, 11 Dec 2025 15:55:52 +0530 Subject: [PATCH 06/10] header refactor --- src/components/common/ScreenHeader.tsx | 240 ++++++++++++ src/screens/DownloadsScreen.tsx | 54 +-- src/screens/LibraryScreen.tsx | 264 +++++-------- src/screens/SearchScreen.tsx | 511 ++++++++++++------------- 4 files changed, 592 insertions(+), 477 deletions(-) create mode 100644 src/components/common/ScreenHeader.tsx diff --git a/src/components/common/ScreenHeader.tsx b/src/components/common/ScreenHeader.tsx new file mode 100644 index 0000000..6b675d0 --- /dev/null +++ b/src/components/common/ScreenHeader.tsx @@ -0,0 +1,240 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + StatusBar, + Platform, +} from 'react-native'; +import { useTheme } from '../../contexts/ThemeContext'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Feather, MaterialIcons } from '@expo/vector-icons'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +interface ScreenHeaderProps { + /** + * The main title displayed in the header + */ + title: string; + /** + * Optional right action button (icon name from Feather icons) + */ + rightActionIcon?: string; + /** + * Optional callback for right action button press + */ + onRightActionPress?: () => void; + /** + * Optional custom right action component (overrides rightActionIcon) + */ + rightActionComponent?: React.ReactNode; + /** + * Optional back button (shows arrow back icon) + */ + showBackButton?: boolean; + /** + * Optional callback for back button press + */ + onBackPress?: () => void; + /** + * Whether this screen is displayed on a tablet layout + */ + isTablet?: boolean; + /** + * Optional extra top padding for tablet navigation offset + */ + tabletNavOffset?: number; + /** + * Optional custom title component (overrides title text) + */ + titleComponent?: React.ReactNode; + /** + * Optional children to render below the title row (e.g., filters, search bar) + */ + children?: React.ReactNode; + /** + * Whether to hide the header title row (useful when showing only children) + */ + hideTitleRow?: boolean; + /** + * Use MaterialIcons instead of Feather for icons + */ + useMaterialIcons?: boolean; + /** + * Optional custom style for title + */ + titleStyle?: object; +} + +const ScreenHeader: React.FC = ({ + title, + rightActionIcon, + onRightActionPress, + rightActionComponent, + showBackButton = false, + onBackPress, + isTablet = false, + tabletNavOffset = 64, + titleComponent, + children, + hideTitleRow = false, + useMaterialIcons = false, + titleStyle, +}) => { + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + + // Calculate header spacing + const topSpacing = + (Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT : insets.top) + + (isTablet ? tabletNavOffset : 0); + + const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; + const titleRowHeight = headerBaseHeight + topSpacing; + + const IconComponent = useMaterialIcons ? MaterialIcons : Feather; + const backIconName = useMaterialIcons ? 'arrow-back' : 'arrow-left'; + + return ( + <> + {/* Fixed position header background to prevent shifts */} + + + {/* Header Section */} + + {/* Title Row */} + {!hideTitleRow && ( + + + {showBackButton ? ( + + + + ) : null} + + {titleComponent ? ( + titleComponent + ) : ( + + {title} + + )} + + {/* Right Action */} + {rightActionComponent ? ( + {rightActionComponent} + ) : rightActionIcon && onRightActionPress ? ( + + + + ) : ( + + )} + + + )} + + {/* Children (filters, search bar, etc.) */} + {children} + + + ); +}; + +const styles = StyleSheet.create({ + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 10, + }, + header: { + paddingHorizontal: 20, + zIndex: 11, + }, + titleRow: { + justifyContent: 'flex-end', + paddingBottom: 8, + }, + headerContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + backButton: { + padding: 8, + marginLeft: -8, + marginRight: 8, + }, + headerTitle: { + fontSize: 32, + fontWeight: '800', + letterSpacing: 0.5, + flex: 1, + }, + headerTitleWithBack: { + fontSize: 24, + flex: 0, + }, + rightActionContainer: { + minWidth: 40, + alignItems: 'flex-end', + }, + rightActionButton: { + padding: 8, + marginRight: -8, + }, + rightActionPlaceholder: { + width: 40, + }, +}); + +export default ScreenHeader; diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index db0fedc..96710d3 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -34,6 +34,7 @@ import { VideoPlayerService } from '../services/videoPlayerService'; import type { DownloadItem } from '../contexts/DownloadsContext'; import { useToast } from '../contexts/ToastContext'; import CustomAlert from '../components/CustomAlert'; +import ScreenHeader from '../components/common/ScreenHeader'; const { height, width } = Dimensions.get('window'); const isTablet = width >= 768; @@ -346,7 +347,6 @@ const DownloadsScreen: React.FC = () => { const navigation = useNavigation>(); const { currentTheme } = useTheme(); const { settings } = useSettings(); - const { top: safeAreaTop } = useSafeAreaInsets(); const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads(); const { showSuccess, showInfo } = useToast(); @@ -356,9 +356,6 @@ const DownloadsScreen: React.FC = () => { const [showRemoveAlert, setShowRemoveAlert] = useState(false); const [pendingRemoveItem, setPendingRemoveItem] = useState(null); - // Animation values - const headerOpacity = useSharedValue(1); - // Filter downloads based on selected filter const filteredDownloads = useMemo(() => { if (selectedFilter === 'all') return downloads; @@ -571,11 +568,6 @@ const DownloadsScreen: React.FC = () => { }, []) ); - // Animated styles - const headerStyle = useAnimatedStyle(() => ({ - opacity: headerOpacity.value, - })); - const renderFilterButton = (filter: typeof selectedFilter, label: string, count: number) => ( { backgroundColor="transparent" /> - {/* Header */} - - - - Downloads - + {/* ScreenHeader Component */} + { color={currentTheme.colors.mediumEmphasis} /> - - + } + isTablet={isTablet} + > {downloads.length > 0 && ( {renderFilterButton('all', 'All', stats.total)} @@ -669,7 +650,7 @@ const DownloadsScreen: React.FC = () => { {renderFilterButton('paused', 'Paused', stats.paused)} )} - + {/* Content */} {downloads.length === 0 ? ( @@ -742,23 +723,6 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - header: { - paddingHorizontal: isTablet ? 24 : Math.max(1, width * 0.05), - paddingBottom: isTablet ? 20 : 16, - borderBottomWidth: StyleSheet.hairlineWidth, - }, - headerTitleRow: { - flexDirection: 'row', - alignItems: 'flex-end', - justifyContent: 'space-between', - marginBottom: isTablet ? 20 : 16, - paddingBottom: 8, - }, - headerTitle: { - fontSize: isTablet ? 36 : Math.min(32, width * 0.08), - fontWeight: '800', - letterSpacing: 0.3, - }, helpButton: { padding: 8, marginLeft: 8, diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 470575e..36d5103 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -4,6 +4,7 @@ import { Share } from 'react-native'; import { mmkvStorage } from '../services/mmkvStorage'; import { useToast } from '../contexts/ToastContext'; import DropUpMenu from '../components/home/DropUpMenu'; +import ScreenHeader from '../components/common/ScreenHeader'; import { View, Text, @@ -107,7 +108,7 @@ const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item: navigation.navigate('Metadata', { id: item.imdbId, type: item.type }); } }, [navigation, item.imdbId, item.type]); - + return ( { const renderSkeletonItem = () => ( - - ); @@ -212,7 +213,7 @@ const LibraryScreen = () => { const [selectedItem, setSelectedItem] = useState(null); const insets = useSafeAreaInsets(); const { currentTheme } = useTheme(); - + // Trakt integration const { isAuthenticated: traktAuthenticated, @@ -272,14 +273,14 @@ const LibraryScreen = () => { setLoading(true); try { const items = await catalogService.getLibraryItems(); - + // Sort by date added (most recent first) const sortedItems = items.sort((a, b) => { const timeA = (a as any).addedToLibraryAt || 0; const timeB = (b as any).addedToLibraryAt || 0; return timeB - timeA; // Descending order (newest first) }); - + // Load watched status for each item from AsyncStorage const updatedItems = await Promise.all(sortedItems.map(async (item) => { // Map StreamingContent to LibraryItem shape @@ -313,7 +314,7 @@ const LibraryScreen = () => { const timeB = (b as any).addedToLibraryAt || 0; return timeB - timeA; // Descending order (newest first) }); - + // Sync watched status on update const updatedItems = await Promise.all(sortedItems.map(async (item) => { // Map StreamingContent to LibraryItem shape @@ -403,8 +404,8 @@ const LibraryScreen = () => { activeOpacity={0.7} > - - + { )} - + {item.name} @@ -444,11 +445,11 @@ const LibraryScreen = () => { > - {folder.name} @@ -724,8 +725,8 @@ const LibraryScreen = () => { Your Trakt collections will appear here once you start using Trakt - { // Show collection folders return ( - renderTraktCollectionFolder({ folder: item })} keyExtractor={item => item.id} numColumns={numColumns} contentContainerStyle={styles.listContainer} showsVerticalScrollIndicator={false} - onEndReachedThreshold={0.7} - onEndReached={() => {}} + onEndReachedThreshold={0.7} + onEndReached={() => { }} /> ); } // Show content for specific folder const folderItems = getTraktFolderItems(selectedTraktFolder); - + if (folderItems.length === 0) { const folderName = traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'; return ( @@ -767,8 +768,8 @@ const LibraryScreen = () => { This collection is empty - { contentContainerStyle={{ paddingBottom: insets.bottom + 80 }} showsVerticalScrollIndicator={false} onEndReachedThreshold={0.7} - onEndReached={() => {}} + onEndReached={() => { }} /> ); }; const renderFilter = (filterType: 'trakt' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => { const isActive = filter === filterType; - + return ( { const emptySubtitle = 'Add some content to your library to see it here'; return ( - @@ -869,8 +870,8 @@ const LibraryScreen = () => { {emptySubtitle} - { contentContainerStyle={styles.listContainer} showsVerticalScrollIndicator={false} onEndReachedThreshold={0.7} - onEndReached={() => {}} + onEndReached={() => { }} /> ); }; - const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; // Tablet detection aligned with navigation tablet logic const isTablet = useMemo(() => { const smallestDimension = Math.min(width, height); return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768); }, [width, height]); - // Keep header below floating top navigator on tablets - const tabletNavOffset = isTablet ? 64 : 0; - const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset; - const headerHeight = headerBaseHeight + topSpacing; return ( - {/* Fixed position header background to prevent shifts */} - - - - {/* Header Section with proper top spacing */} - - - {showTraktContent ? ( - <> - { - if (selectedTraktFolder) { - setSelectedTraktFolder(null); - } else { - setShowTraktContent(false); - } - }} - activeOpacity={0.7} - > - - - - {selectedTraktFolder - ? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection' - : 'Trakt Collection' - } - - - ) : ( - <> - Library - navigation.navigate('Calendar')} - activeOpacity={0.7} - > - - - - )} - - + {/* ScreenHeader Component */} + f.id === selectedTraktFolder)?.name || 'Collection' + : 'Trakt Collection') + : 'Library' + } + showBackButton={showTraktContent} + onBackPress={showTraktContent ? () => { + if (selectedTraktFolder) { + setSelectedTraktFolder(null); + } else { + setShowTraktContent(false); + } + } : undefined} + useMaterialIcons={showTraktContent} + rightActionIcon={!showTraktContent ? 'calendar' : undefined} + onRightActionPress={!showTraktContent ? () => navigation.navigate('Calendar') : undefined} + isTablet={isTablet} + /> - {/* Content Container */} - - {!showTraktContent && ( - // Replaced ScrollView with View and used the modified style - - {renderFilter('trakt', 'Trakt', 'pan-tool')} - {renderFilter('movies', 'Movies', 'movie')} - {renderFilter('series', 'TV Shows', 'live-tv')} - - )} - - {showTraktContent ? renderTraktContent() : renderContent()} + {/* Content Container */} + + {!showTraktContent && ( + + {renderFilter('trakt', 'Trakt', 'pan-tool')} + {renderFilter('movies', 'Movies', 'movie')} + {renderFilter('series', 'TV Shows', 'live-tv')} - + )} + + {showTraktContent ? renderTraktContent() : renderContent()} + {/* DropUpMenu integration */} {selectedItem && ( @@ -991,45 +953,45 @@ const LibraryScreen = () => { if (!selectedItem) return; switch (option) { case 'library': { - try { - await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); - showInfo('Removed from Library', 'Item removed from your library'); - setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type))); - setMenuVisible(false); - } catch (error) { - showError('Failed to update Library', 'Unable to remove item from library'); - } - break; + try { + await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); + showInfo('Removed from Library', 'Item removed from your library'); + setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type))); + setMenuVisible(false); + } catch (error) { + showError('Failed to update Library', 'Unable to remove item from library'); + } + break; } case 'watched': { - try { - // Use AsyncStorage to store watched status by key - const key = `watched:${selectedItem.type}:${selectedItem.id}`; - const newWatched = !selectedItem.watched; - await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); - showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched'); - // Instantly update local state - setLibraryItems(prev => prev.map(item => - item.id === selectedItem.id && item.type === selectedItem.type - ? { ...item, watched: newWatched } - : item - )); - } catch (error) { - showError('Failed to update watched status', 'Unable to update watched status'); - } - break; + try { + // Use AsyncStorage to store watched status by key + const key = `watched:${selectedItem.type}:${selectedItem.id}`; + const newWatched = !selectedItem.watched; + await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); + showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched'); + // Instantly update local state + setLibraryItems(prev => prev.map(item => + item.id === selectedItem.id && item.type === selectedItem.type + ? { ...item, watched: newWatched } + : item + )); + } catch (error) { + showError('Failed to update watched status', 'Unable to update watched status'); + } + break; } case 'share': { - let url = ''; - if (selectedItem.id) { - url = `https://www.imdb.com/title/${selectedItem.id}/`; - } - const message = `${selectedItem.name}\n${url}`; - Share.share({ message, url, title: selectedItem.name }); - break; + let url = ''; + if (selectedItem.id) { + url = `https://www.imdb.com/title/${selectedItem.id}/`; + } + const message = `${selectedItem.name}\n${url}`; + Share.share({ message, url, title: selectedItem.name }); + break; } default: - break; + break; } }} /> @@ -1042,13 +1004,6 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - headerBackground: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 1, - }, watchedIndicator: { position: 'absolute', top: 8, @@ -1060,23 +1015,6 @@ const styles = StyleSheet.create({ contentContainer: { flex: 1, }, - header: { - paddingHorizontal: 20, - justifyContent: 'flex-end', - paddingBottom: 8, - backgroundColor: 'transparent', - zIndex: 2, - }, - headerContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - headerTitle: { - fontSize: 32, - fontWeight: '800', - letterSpacing: 0.5, - }, filtersContainer: { flexDirection: 'row', justifyContent: 'center', @@ -1130,7 +1068,7 @@ const styles = StyleSheet.create({ borderRadius: 12, overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.03)', - aspectRatio: 2/3, + aspectRatio: 2 / 3, elevation: 5, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, @@ -1253,7 +1191,7 @@ const styles = StyleSheet.create({ borderRadius: 8, overflow: 'hidden', backgroundColor: 'rgba(255,255,255,0.03)', - aspectRatio: 2/3, + aspectRatio: 2 / 3, elevation: 5, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 04b74d8..13d9a5c 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -27,11 +27,11 @@ import debounce from 'lodash/debounce'; import { DropUpMenu } from '../components/home/DropUpMenu'; import { DeviceEventEmitter, Share } from 'react-native'; import { mmkvStorage } from '../services/mmkvStorage'; -import Animated, { - FadeIn, - FadeOut, - useAnimatedStyle, - useSharedValue, +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + useSharedValue, withTiming, interpolate, withSpring, @@ -43,6 +43,7 @@ import { BlurView } from 'expo-blur'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; import LoadingSpinner from '../components/common/LoadingSpinner'; +import ScreenHeader from '../components/common/ScreenHeader'; const { width, height } = Dimensions.get('window'); @@ -110,21 +111,21 @@ const SkeletonLoader = () => { const renderSkeletonItem = () => ( @@ -138,7 +139,7 @@ const SkeletonLoader = () => { {index === 0 && ( )} @@ -157,7 +158,7 @@ const SimpleSearchAnimation = () => { const spinAnim = React.useRef(new RNAnimated.Value(0)).current; const fadeAnim = React.useRef(new RNAnimated.Value(0)).current; const { currentTheme } = useTheme(); - + React.useEffect(() => { // Rotation animation const spin = RNAnimated.loop( @@ -168,32 +169,32 @@ const SimpleSearchAnimation = () => { useNativeDriver: true, }) ); - + // Fade animation const fade = RNAnimated.timing(fadeAnim, { toValue: 1, duration: 300, useNativeDriver: true, }); - + // Start animations spin.start(); fade.start(); - + // Clean up return () => { spin.stop(); }; }, [spinAnim, fadeAnim]); - + // Simple rotation interpolation const spin = spinAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'], }); - + return ( - { styles.spinnerContainer, { transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary } ]}> - Searching @@ -268,9 +269,9 @@ const SearchScreen = () => { StatusBar.setBackgroundColor('transparent'); } }; - + applyStatusBarConfig(); - + // Re-apply on focus const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); return unsubscribe; @@ -284,7 +285,7 @@ const SearchScreen = () => { useEffect(() => { loadRecentSearches(); - + // Cleanup function to cancel pending searches on unmount return () => { debouncedSearch.cancel(); @@ -302,12 +303,12 @@ const SearchScreen = () => { return { opacity: backButtonOpacity.value, transform: [ - { + { translateX: interpolate( backButtonOpacity.value, [0, 1], [-20, 0] - ) + ) } ] }; @@ -361,14 +362,14 @@ const SearchScreen = () => { const saveRecentSearch = async (searchQuery: string) => { try { setRecentSearches(prevSearches => { - const newRecentSearches = [ - searchQuery, + const newRecentSearches = [ + searchQuery, ...prevSearches.filter(s => s !== searchQuery) - ].slice(0, MAX_RECENT_SEARCHES); - + ].slice(0, MAX_RECENT_SEARCHES); + // Save to AsyncStorage mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); - + return newRecentSearches; }); } catch (error) { @@ -400,7 +401,7 @@ const SearchScreen = () => { const rank: Record = {}; addons.forEach((a, idx) => { rank[a.id] = idx; }); addonOrderRankRef.current = rank; - } catch {} + } catch { } const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => { // Append/update this addon section immediately with minimal changes @@ -444,7 +445,7 @@ const SearchScreen = () => { // Save to recents after first result batch try { await saveRecentSearch(searchQuery); - } catch {} + } catch { } }); liveSearchHandle.current = handle; }, 800); @@ -502,7 +503,7 @@ const SearchScreen = () => { if (!showRecent || recentSearches.length === 0) return null; return ( - @@ -586,10 +587,10 @@ const SearchScreen = () => { entering={FadeIn.duration(300).delay(index * 50)} activeOpacity={0.7} > - + }]}> { /> {/* Bookmark and watched icons top right, bookmark to the left of watched */} {inLibrary && ( - + )} {watched && ( - + )} {item.imdbRating && ( - + {item.imdbRating} )} - { {item.name} {item.year && ( - + {item.year} )} ); }; - + const hasResultsToShow = useMemo(() => { return results.byAddon.length > 0; }, [results]); // Memoized addon section to prevent re-rendering unchanged sections - const AddonSection = React.memo(({ - addonGroup, - addonIndex - }: { - addonGroup: AddonSearchResults; + const AddonSection = React.memo(({ + addonGroup, + addonIndex + }: { + addonGroup: AddonSearchResults; addonIndex: number; }) => { - const movieResults = useMemo(() => - addonGroup.results.filter(item => item.type === 'movie'), + const movieResults = useMemo(() => + addonGroup.results.filter(item => item.type === 'movie'), [addonGroup.results] ); - const seriesResults = useMemo(() => - addonGroup.results.filter(item => item.type === 'series'), + const seriesResults = useMemo(() => + addonGroup.results.filter(item => item.type === 'series'), [addonGroup.results] ); - const otherResults = useMemo(() => - addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'), + const otherResults = useMemo(() => + addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'), [addonGroup.results] ); @@ -679,15 +680,15 @@ const SearchScreen = () => { {/* Movies */} {movieResults.length > 0 && ( - + Movies ({movieResults.length}) { {/* TV Shows */} {seriesResults.length > 0 && ( - + TV Shows ({seriesResults.length}) { {/* Other types */} {otherResults.length > 0 && ( - + {otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length}) { return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex; }); - const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; - // Keep header below floating top navigator on tablets by adding extra offset - const tabletNavOffset = (isTV || isLargeTablet || isTablet) ? 64 : 0; - const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset; - const headerHeight = headerBaseHeight + topSpacing + 60; - // Set up listeners for watched status and library updates // These will trigger re-renders in individual SearchResultItem components useEffect(() => { @@ -809,11 +804,11 @@ const SearchScreen = () => { }, []); return ( - @@ -822,172 +817,170 @@ const SearchScreen = () => { backgroundColor="transparent" translucent /> - {/* Fixed position header background to prevent shifts */} - - - {/* Header Section with proper top spacing */} - - Search - + + {/* ScreenHeader Component */} + + {/* Search Bar */} + + - - - - {query.length > 0 && ( - - - - )} - + + + {query.length > 0 && ( + + + + )} - {/* Content Container */} - - {searching ? ( - - + + {/* Content Container */} + + {searching ? ( + + + + ) : query.trim().length === 1 ? ( + + + + Keep typing... + + + Type at least 2 characters to search + + + ) : searched && !hasResultsToShow ? ( + + + + No results found + + + Try different keywords or check your spelling + + + ) : ( + + {!query.trim() && renderRecentSearches()} + {/* Render results grouped by addon using memoized component */} + {results.byAddon.map((addonGroup, addonIndex) => ( + - - ) : query.trim().length === 1 ? ( - - - - Keep typing... - - - Type at least 2 characters to search - - - ) : searched && !hasResultsToShow ? ( - - - - No results found - - - Try different keywords or check your spelling - - - ) : ( - - {!query.trim() && renderRecentSearches()} - {/* Render results grouped by addon using memoized component */} - {results.byAddon.map((addonGroup, addonIndex) => ( - - ))} - - )} - - {/* DropUpMenu integration for search results */} - {selectedItem && ( - setMenuVisible(false)} - item={selectedItem} - isSaved={isSaved} - isWatched={isWatched} - onOptionSelect={async (option: string) => { - if (!selectedItem) return; - switch (option) { - case 'share': { - let url = ''; - if (selectedItem.id) { - url = `https://www.imdb.com/title/${selectedItem.id}/`; - } - const message = `${selectedItem.name}\n${url}`; - Share.share({ message, url, title: selectedItem.name }); - break; - } - case 'library': { - if (isSaved) { - await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); - setIsSaved(false); - } else { - await catalogService.addToLibrary(selectedItem); - setIsSaved(true); - } - break; - } - case 'watched': { - const key = `watched:${selectedItem.type}:${selectedItem.id}`; - const newWatched = !isWatched; - await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); - setIsWatched(newWatched); - break; - } - default: - break; - } - }} - /> + ))} + )} + {/* DropUpMenu integration for search results */} + {selectedItem && ( + setMenuVisible(false)} + item={selectedItem} + isSaved={isSaved} + isWatched={isWatched} + onOptionSelect={async (option: string) => { + if (!selectedItem) return; + switch (option) { + case 'share': { + let url = ''; + if (selectedItem.id) { + url = `https://www.imdb.com/title/${selectedItem.id}/`; + } + const message = `${selectedItem.name}\n${url}`; + Share.share({ message, url, title: selectedItem.name }); + break; + } + case 'library': { + if (isSaved) { + await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); + setIsSaved(false); + } else { + await catalogService.addToLibrary(selectedItem); + setIsSaved(true); + } + break; + } + case 'watched': { + const key = `watched:${selectedItem.type}:${selectedItem.id}`; + const newWatched = !isWatched; + await mmkvStorage.setItem(key, newWatched ? 'true' : 'false'); + setIsWatched(newWatched); + break; + } + default: + break; + } + }} + /> + )} ); }; @@ -996,30 +989,10 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - headerBackground: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - zIndex: 1, - }, contentContainer: { flex: 1, paddingTop: 0, }, - header: { - paddingHorizontal: 15, - justifyContent: 'flex-end', - paddingBottom: 0, - backgroundColor: 'transparent', - zIndex: 2, - }, - headerTitle: { - fontSize: 32, - fontWeight: '800', - letterSpacing: 0.5, - marginBottom: 12, - }, searchBarContainer: { flexDirection: 'row', alignItems: 'center', From bf22e559c5539ebead00606c9928f707c0bb39ed Mon Sep 17 00:00:00 2001 From: tapframe Date: Thu, 11 Dec 2025 16:10:59 +0530 Subject: [PATCH 07/10] header padding fix --- src/components/common/ScreenHeader.tsx | 1 + src/screens/SettingsScreen.tsx | 28 +++++--------------------- 2 files changed, 6 insertions(+), 23 deletions(-) diff --git a/src/components/common/ScreenHeader.tsx b/src/components/common/ScreenHeader.tsx index 6b675d0..2ab1a4f 100644 --- a/src/components/common/ScreenHeader.tsx +++ b/src/components/common/ScreenHeader.tsx @@ -151,6 +151,7 @@ const ScreenHeader: React.FC = ({ style={[ styles.headerTitle, { color: currentTheme.colors.text }, + isTablet && { fontSize: 48 }, // Increase font size for tablet showBackButton && styles.headerTitleWithBack, titleStyle, ]} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 27b5f4a..2c7a4f2 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -33,6 +33,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import * as Sentry from '@sentry/react-native'; import { getDisplayedAppVersion } from '../utils/version'; import CustomAlert from '../components/CustomAlert'; +import ScreenHeader from '../components/common/ScreenHeader'; import PluginIcon from '../components/icons/PluginIcon'; import TraktIcon from '../components/icons/TraktIcon'; import TMDBIcon from '../components/icons/TMDBIcon'; @@ -883,11 +884,8 @@ const SettingsScreen: React.FC = () => { } }; - const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; // Keep headers below floating top navigator on tablets by adding extra offset const tabletNavOffset = isTablet ? 64 : 0; - const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset; - const headerHeight = headerBaseHeight + topSpacing; if (isTablet) { return ( @@ -1010,12 +1008,10 @@ const SettingsScreen: React.FC = () => { { backgroundColor: currentTheme.colors.darkBackground } ]}> + - - - Settings - - Date: Thu, 11 Dec 2025 18:56:51 +0530 Subject: [PATCH 08/10] fixed a continue watching bug where removed content won't reappearing even after watching it again --- src/services/storageService.ts | 125 ++++++++++++++++++--------------- 1 file changed, 70 insertions(+), 55 deletions(-) diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 4c7b360..9a2b4f7 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -29,7 +29,7 @@ class StorageService { private watchProgressCacheTimestamp = 0; private readonly WATCH_PROGRESS_CACHE_TTL = 5000; // 5 seconds - private constructor() {} + private constructor() { } public static getInstance(): StorageService { if (!StorageService.instance) { @@ -88,7 +88,7 @@ class StorageService { const map = JSON.parse(json) as Record; map[this.buildWpKeyString(id, type, episodeId)] = deletedAtMs || Date.now(); await mmkvStorage.setItem(key, JSON.stringify(map)); - } catch {} + } catch { } } public async clearWatchProgressTombstone( @@ -105,7 +105,7 @@ class StorageService { delete map[k]; await mmkvStorage.setItem(key, JSON.stringify(map)); } - } catch {} + } catch { } } public async getWatchProgressTombstones(): Promise> { @@ -220,7 +220,7 @@ class StorageService { lastUpdated: Date.now() }; await this.setWatchProgress(id, type, updatedProgress, episodeId); - logger.log(`[StorageService] Updated progress duration from ${(existingProgress.duration/60).toFixed(0)}min to ${(newDuration/60).toFixed(0)}min`); + logger.log(`[StorageService] Updated progress duration from ${(existingProgress.duration / 60).toFixed(0)}min to ${(newDuration / 60).toFixed(0)}min`); } } catch (error) { logger.error('Error updating progress duration:', error); @@ -247,15 +247,15 @@ class StorageService { if (newestTombAt && (progress.lastUpdated == null || progress.lastUpdated <= newestTombAt)) { return; } - } catch {} - + } catch { } + // Check if progress has actually changed significantly, unless forceWrite is requested if (!options?.forceWrite) { const existingProgress = await this.getWatchProgress(id, type, episodeId); if (existingProgress) { const timeDiff = Math.abs(progress.currentTime - existingProgress.currentTime); const durationDiff = Math.abs(progress.duration - existingProgress.duration); - + // Only update if there's a significant change (>5 seconds or duration change) if (timeDiff < 5 && durationDiff < 1) { return; // Skip update for minor changes @@ -266,9 +266,24 @@ class StorageService { const timestamp = (options?.preserveTimestamp && typeof progress.lastUpdated === 'number') ? progress.lastUpdated : Date.now(); + + + try { + const removedMap = await this.getContinueWatchingRemoved(); + const removedKey = this.buildWpKeyString(id, type); + const removedAt = removedMap[removedKey]; + + if (removedAt != null && timestamp > removedAt) { + logger.log(`♻️ [StorageService] restoring content to continue watching due to new progress: ${type}:${id}`); + await this.removeContinueWatchingRemoved(id, type); + } + } catch (e) { + // Ignore error checks for restoration to prevent blocking save + } + const updated = { ...progress, lastUpdated: timestamp }; await mmkvStorage.setItem(key, JSON.stringify(updated)); - + // Invalidate cache this.invalidateWatchProgressCache(); @@ -285,12 +300,12 @@ class StorageService { private debouncedNotifySubscribers(): void { const now = Date.now(); - + // Clear existing timer if (this.notificationDebounceTimer) { clearTimeout(this.notificationDebounceTimer); } - + // If we notified recently, debounce longer const timeSinceLastNotification = now - this.lastNotificationTime; if (timeSinceLastNotification < this.MIN_NOTIFICATION_INTERVAL) { @@ -306,16 +321,16 @@ class StorageService { private notifyWatchProgressSubscribers(): void { this.lastNotificationTime = Date.now(); this.notificationDebounceTimer = null; - + // Only notify if we have subscribers if (this.watchProgressSubscribers.length > 0) { - this.watchProgressSubscribers.forEach(callback => callback()); + this.watchProgressSubscribers.forEach(callback => callback()); } } public subscribeToWatchProgressUpdates(callback: () => void): () => void { this.watchProgressSubscribers.push(callback); - + // Return unsubscribe function return () => { const index = this.watchProgressSubscribers.indexOf(callback); @@ -334,7 +349,7 @@ class StorageService { } public async getWatchProgress( - id: string, + id: string, type: string, episodeId?: string ): Promise { @@ -349,7 +364,7 @@ class StorageService { } public async removeWatchProgress( - id: string, + id: string, type: string, episodeId?: string ): Promise { @@ -357,14 +372,14 @@ class StorageService { const key = await this.getWatchProgressKeyScoped(id, type, episodeId); await mmkvStorage.removeItem(key); await this.addWatchProgressTombstone(id, type, episodeId); - + // Invalidate cache this.invalidateWatchProgressCache(); - + // Notify subscribers this.notifyWatchProgressSubscribers(); // Emit explicit remove event for sync layer - try { this.watchProgressRemoveListeners.forEach(l => l(id, type, episodeId)); } catch {} + try { this.watchProgressRemoveListeners.forEach(l => l(id, type, episodeId)); } catch { } } catch (error) { logger.error('Error removing watch progress:', error); } @@ -383,25 +398,25 @@ class StorageService { const keys = await mmkvStorage.getAllKeys(); const watchProgressKeys = keys.filter(key => key.startsWith(prefix)); const pairs = await mmkvStorage.multiGet(watchProgressKeys); - + const result = pairs.reduce((acc, [key, value]) => { if (value) { acc[key.replace(prefix, '')] = JSON.parse(value); } return acc; }, {} as Record); - + // Update cache this.watchProgressCache = result; this.watchProgressCacheTimestamp = now; - + return result; } catch (error) { logger.error('Error getting all watch progress:', error); return {}; } } - + private invalidateWatchProgressCache(): void { this.watchProgressCache = null; this.watchProgressCacheTimestamp = 0; @@ -419,7 +434,7 @@ class StorageService { exactTime?: number ): Promise { try { - const existingProgress = await this.getWatchProgress(id, type, episodeId); + const existingProgress = await this.getWatchProgress(id, type, episodeId); if (existingProgress) { // Preserve the highest Trakt progress and currentTime values to avoid accidental regressions const highestTraktProgress = (() => { @@ -479,9 +494,9 @@ class StorageService { continue; } // Check if needs sync (either never synced or local progress is newer) - const needsSync = !progress.traktSynced || + const needsSync = !progress.traktSynced || (progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced); - + if (needsSync) { const parts = key.split(':'); const type = parts[0]; @@ -517,14 +532,14 @@ class StorageService { ): Promise { try { logger.log(`🗑️ [StorageService] removeAllWatchProgressForContent called for ${type}:${id}`); - + const all = await this.getAllWatchProgress(); const prefix = `${type}:${id}`; logger.log(`🔍 [StorageService] Looking for keys with prefix: ${prefix}`); - + const matchingKeys = Object.keys(all).filter(key => key === prefix || key.startsWith(`${prefix}:`)); logger.log(`📊 [StorageService] Found ${matchingKeys.length} matching keys:`, matchingKeys); - + const removals: Array> = []; for (const key of matchingKeys) { // Compute episodeId if present @@ -532,16 +547,16 @@ class StorageService { logger.log(`🗑️ [StorageService] Removing progress for key: ${key} (episodeId: ${episodeId})`); removals.push(this.removeWatchProgress(id, type, episodeId)); } - + await Promise.allSettled(removals); logger.log(`✅ [StorageService] All watch progress removals completed`); - + if (options?.addBaseTombstone) { logger.log(`🪦 [StorageService] Adding tombstone for ${type}:${id}`); await this.addWatchProgressTombstone(id, type); logger.log(`✅ [StorageService] Tombstone added successfully`); } - + logger.log(`✅ [StorageService] removeAllWatchProgressForContent completed for ${type}:${id}`); } catch (error) { logger.error(`❌ [StorageService] Error removing all watch progress for content ${type}:${id}:`, error); @@ -562,12 +577,12 @@ class StorageService { try { const localProgress = await this.getWatchProgress(id, type, episodeId); const traktTimestamp = new Date(traktPausedAt).getTime(); - + if (!localProgress) { // No local progress - use stored duration or estimate let duration = await this.getContentDuration(id, type, episodeId); let currentTime: number; - + if (exactTime && exactTime > 0) { // Use exact time from Trakt if available currentTime = exactTime; @@ -589,7 +604,7 @@ class StorageService { } currentTime = (traktProgress / 100) * duration; } - + const newProgress: WatchProgress = { currentTime, duration, @@ -599,41 +614,41 @@ class StorageService { traktProgress }; await this.setWatchProgress(id, type, newProgress, episodeId); - + // Progress creation logging removed } else { // Local progress exists - merge intelligently const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100; - + // Only proceed if there's a significant difference (>5% or different completion status) const progressDiff = Math.abs(traktProgress - localProgressPercent); if (progressDiff < 5 && traktProgress < 100 && localProgressPercent < 100) { return; // Skip minor updates } - + let currentTime: number; let duration = localProgress.duration; - + if (exactTime && exactTime > 0 && localProgress.duration > 0) { // Use exact time from Trakt, keep local duration currentTime = exactTime; - + // If exact time doesn't match the duration well, recalculate duration const calculatedDuration = (exactTime / traktProgress) * 100; const durationDiff = Math.abs(calculatedDuration - localProgress.duration); if (durationDiff > 300) { // More than 5 minutes difference duration = calculatedDuration; - logger.log(`[StorageService] Updated duration based on exact time: ${(localProgress.duration/60).toFixed(0)}min → ${(duration/60).toFixed(0)}min`); + logger.log(`[StorageService] Updated duration based on exact time: ${(localProgress.duration / 60).toFixed(0)}min → ${(duration / 60).toFixed(0)}min`); } } else if (localProgress.duration > 0) { // Use percentage calculation with local duration currentTime = (traktProgress / 100) * localProgress.duration; } else { - // No local duration, check stored duration - const storedDuration = await this.getContentDuration(id, type, episodeId); - duration = storedDuration || 0; - - if (!duration || duration <= 0) { + // No local duration, check stored duration + const storedDuration = await this.getContentDuration(id, type, episodeId); + duration = storedDuration || 0; + + if (!duration || duration <= 0) { if (exactTime && exactTime > 0) { duration = (exactTime / traktProgress) * 100; currentTime = exactTime; @@ -649,21 +664,21 @@ class StorageService { currentTime = (traktProgress / 100) * duration; } } else { - currentTime = exactTime && exactTime > 0 ? exactTime : (traktProgress / 100) * duration; + currentTime = exactTime && exactTime > 0 ? exactTime : (traktProgress / 100) * duration; } } - - const updatedProgress: WatchProgress = { + + const updatedProgress: WatchProgress = { ...localProgress, currentTime, duration, - lastUpdated: traktTimestamp, - traktSynced: true, - traktLastSynced: Date.now(), - traktProgress - }; - await this.setWatchProgress(id, type, updatedProgress, episodeId); - + lastUpdated: traktTimestamp, + traktSynced: true, + traktLastSynced: Date.now(), + traktProgress + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + // Progress update logging removed } } catch (error) { From 8a34bf6678cd77698c420f5499df875fc0ccb6b2 Mon Sep 17 00:00:00 2001 From: tapframe Date: Fri, 12 Dec 2025 14:12:01 +0530 Subject: [PATCH 09/10] update privacy policy --- index.html | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/index.html b/index.html index d3242d1..c11fbe2 100644 --- a/index.html +++ b/index.html @@ -1753,41 +1753,44 @@

Last updated: January 2025

-

Data Collection

-

Nuvio does not collect personal information. We only store:

-
    -
  • User preferences: App settings and viewing preferences for account sync
  • -
  • Viewing history: Stored locally on your device for your own use
  • -
+

No Account Sync

+

Nuvio operates entirely offline regarding user data. We do not have servers to store your account, preferences, or viewing history. All data is stored locally on your device.

-

How We Use Your Data

-

Your data is stored for your own purposes:

+

Data Storage & Backup

+

We use React Native MMKV for high-performance local storage. This includes:

    -
  • Sync your preferences and viewing history across your devices
  • -
  • Maintain your app settings and customizations
  • +
  • Library and favorites
  • +
  • Watch history and progress
  • +
  • App settings and customization
-

We do not personalize recommendations or use your data for any other purposes.

+

Important: Since data is stored only on your device, you are responsible for backing it up. If you uninstall the app or clear its data without a backup, your personalized data will be lost permanently.

Third-Party Services

-

Nuvio integrates with third-party services that have their own privacy policies:

+

Nuvio integrates with external services to provide content and features:

    -
  • Trakt.tv: For tracking viewing progress (optional)
  • -
  • TMDB: For movie and TV show information
  • +
  • TMDB (The Movie Database): Used to fetch metadata like posters, plot summaries, and cast info.
  • +
  • Trakt.tv (Optional): If you choose to connect your account, your watch history will be synced with Trakt.tv subject to their privacy policy.
  • +
  • Sentry: We use Sentry for anonymous crash reporting to help us identify and fix bugs. No personal identifiable information (PII) is sent.
+
+

Content Disclaimer

+

Nuvio is a media player and aggregator. We do not host any content. All video content is provided by user-installed addons. Nuvio has no control over and assumes no responsibility for the content provided by third-party addons.

+
+

Open Source

-

Nuvio is open-source. You can review our code and data handling on our GitHub repository to verify our privacy practices.

+

Nuvio is open-source software. You can review our source code to verify our data handling practices on our GitHub repository.

Contact

-

Questions about this policy? Contact us through our GitHub repository.

+

Questions or concerns? Please reach out via our GitHub Issues.

From 3c35b9975934dee4c5cf7746ef23fcff6c2db78d Mon Sep 17 00:00:00 2001 From: tapframe Date: Sat, 13 Dec 2025 21:09:09 +0530 Subject: [PATCH 10/10] continue watching improvements. testflight link added to readme --- README.md | 3 + index.html | 818 ++++++++++-------- ios/Nuvio.xcodeproj/project.pbxproj | 8 +- ios/Nuvio/Info.plist | 196 ++--- ios/Nuvio/NuvioRelease.entitlements | 12 +- .../home/ContinueWatchingSection.tsx | 138 +-- 6 files changed, 664 insertions(+), 511 deletions(-) diff --git a/README.md b/README.md index b595ced..9b2e057 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,9 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio ### iOS +#### TestFlight (Recommended) + [![Join TestFlight](https://img.shields.io/badge/Join-TestFlight-blue?style=for-the-badge)](https://testflight.apple.com/join/QkKMGRqp) + #### AltStore [![Add to AltStore](https://img.shields.io/badge/Add%20to-AltStore-blue?style=for-the-badge)](https://tinyurl.com/NuvioAltstore) diff --git a/index.html b/index.html index c11fbe2..e617241 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,12 @@ + Nuvio - Media Hub - + +
@@ -1554,20 +1626,21 @@

NUVIO

The Ultimate Open-Source Media Experience

- + - +
-
- Direct Download -
-
-
Direct Download
-
Download the IPA file directly to your device
-
-
- - -
- AltStore -
-
-
Install via AltStore
-
One-click installation through AltStore
-
-
- - -
- SideStore -
-
-
Install via SideStore
-
One-click installation through SideStore
-
-
- + +
+ TestFlight +
+
+
TestFlight (Recommended)
+
Install via Apple's official beta testing platform
+
+
+ + +
+ Direct Download +
+
+
Direct Download
+
Download the IPA file directly to your device
+
+
+ + +
+ AltStore +
+
+
Install via AltStore
+
One-click installation through AltStore
+
+
+ + +
+ SideStore +
+
+
Install via SideStore
+
One-click installation through SideStore
+
+
+
- Copy URL + Copy URL
Copy Source URL
@@ -1639,56 +1725,70 @@
- - - + + +

Stremio Addon Support

-

Full compatibility with Stremio addons, allowing you to access your favorite content providers seamlessly.

+

Full compatibility with Stremio addons, allowing you to access your favorite content providers + seamlessly.

- +
- +

Advanced Rating System

-

Comprehensive rating screens with IMDB, TMDB, Rotten Tomatoes, and Metacritic scores for informed viewing decisions.

+

Comprehensive rating screens with IMDB, TMDB, Rotten Tomatoes, and Metacritic scores for informed + viewing decisions.

- - + +

Deep Customization

-

Extensive customization options including themes, player settings, notification preferences, and personalized content discovery.

+

Extensive customization options including themes, player settings, notification preferences, and + personalized content discovery.

- - + +

Watch Progress Tracking

-

Seamless progress synchronization across devices with Trakt.tv integration and local watch history management.

+

Seamless progress synchronization across devices with Trakt.tv integration and local watch + history management.

- - + +

Multi-Platform Support

-

Available on iOS and Android platforms with consistent experience and cross-device synchronization

+

Available on iOS and Android platforms with consistent experience and cross-device + synchronization

@@ -1698,44 +1798,50 @@

SEE IT IN ACTION

-
- Home Screen -

Home Screen

-
-
- App Interface -

Details Page

-
-
- Home Screen 2 -

Home Screen 2

-
-
- Library -

Library

-
-
- Player Loading -

Player Loading

-
-
- Video Player -

Video Player

-
-
- Ratings -

Ratings

-
-
- Episodes & Seasons -

Episodes & Seasons

-
-
- Search & Details -

Search & Details

-
+
+ Home Screen +

Home Screen

+
+
+ App Interface +

Details Page

+
+
+ Home Screen 2 +

Home Screen 2

+
+
+ Library +

Library

+
+
+ Player Loading +

Player Loading

+
+
+ Video Player +

Video Player

+
+
+ Ratings +

Ratings

+
+
+ Episodes & Seasons +

Episodes & Seasons

+
+
+ Search & Details +

Search & Details

+
-
+
@@ -1745,16 +1851,18 @@

Privacy Policy

Last updated: January 2025

- +

No Account Sync

-

Nuvio operates entirely offline regarding user data. We do not have servers to store your account, preferences, or viewing history. All data is stored locally on your device.

+

Nuvio operates entirely offline regarding user data. We do not have servers to + store your account, preferences, or viewing history. All data is stored locally on your device. +

@@ -1765,32 +1873,43 @@
  • Watch history and progress
  • App settings and customization
  • -

    Important: Since data is stored only on your device, you are responsible for backing it up. If you uninstall the app or clear its data without a backup, your personalized data will be lost permanently.

    +

    Important: Since data is stored only on your device, you are responsible for + backing it up. If you uninstall the app or clear its data without a backup, your personalized + data will be lost permanently.

    Third-Party Services

    Nuvio integrates with external services to provide content and features:

      -
    • TMDB (The Movie Database): Used to fetch metadata like posters, plot summaries, and cast info.
    • -
    • Trakt.tv (Optional): If you choose to connect your account, your watch history will be synced with Trakt.tv subject to their privacy policy.
    • -
    • Sentry: We use Sentry for anonymous crash reporting to help us identify and fix bugs. No personal identifiable information (PII) is sent.
    • +
    • TMDB (The Movie Database): Used to fetch metadata like posters, plot + summaries, and cast info.
    • +
    • Trakt.tv (Optional): If you choose to connect your account, your watch + history will be synced with Trakt.tv subject to their privacy policy.
    • +
    • Sentry: We use Sentry for anonymous crash reporting to help us identify and + fix bugs. No personal identifiable information (PII) is sent.

    Content Disclaimer

    -

    Nuvio is a media player and aggregator. We do not host any content. All video content is provided by user-installed addons. Nuvio has no control over and assumes no responsibility for the content provided by third-party addons.

    +

    Nuvio is a media player and aggregator. We do not host any content. All video + content is provided by user-installed addons. Nuvio has no control over and assumes no + responsibility for the content provided by third-party addons.

    Open Source

    -

    Nuvio is open-source software. You can review our source code to verify our data handling practices on our GitHub repository.

    +

    Nuvio is open-source software. You can review our source code to verify our data handling + practices on our GitHub + repository.

    Contact

    -

    Questions or concerns? Please reach out via our GitHub Issues.

    +

    Questions or concerns? Please reach out via our GitHub Issues. +

    @@ -1801,31 +1920,37 @@

    Special Thanks

    -
    - -
    -
    -
    - - -
    -
    -
    - -
    -
    - -
    -
    +
    + +
    +
    +
    + + +
    +
    +
    + +
    +
    + +
    +

    Built with ❤️ using React Native & Expo

    - - + + - - + - + - + - + + \ No newline at end of file diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index 3808363..ce6a175 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -477,7 +477,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = "Nuvio"; + PRODUCT_NAME = Nuvio; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -494,7 +494,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Nuvio/NuvioRelease.entitlements; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = NLXTHANK2N; + DEVELOPMENT_TEAM = 8QBDZ766S3; INFOPLIST_FILE = Nuvio/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -508,8 +508,8 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app"; - PRODUCT_NAME = "Nuvio"; + PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub; + PRODUCT_NAME = Nuvio; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist index 14731e5..40a35d5 100644 --- a/ios/Nuvio/Info.plist +++ b/ios/Nuvio/Info.plist @@ -1,103 +1,99 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Nuvio - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.2.10 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - nuvio - com.nuvio.app - - - - CFBundleURLSchemes - - exp+nuvio - - - - CFBundleVersion - 25 - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSBonjourServices - - _http._tcp - _googlecast._tcp - _CC1AD845._googlecast._tcp - - NSLocalNetworkUsageDescription - Allow $(PRODUCT_NAME) to access your local network - NSMicrophoneUsageDescription - This app does not require microphone access. - RCTNewArchEnabled - - RCTRootViewBackgroundColor - 4278322180 - UIBackgroundModes - - audio - - UIFileSharingEnabled - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Dark - UIViewControllerBasedStatusBarAppearance - - - \ No newline at end of file + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nuvio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.10 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nuvio + com.nuvio.app + + + + CFBundleURLSchemes + + exp+nuvio + + + + CFBundleVersion + 25 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _http._tcp + _googlecast._tcp + _CC1AD845._googlecast._tcp + + RCTNewArchEnabled + + RCTRootViewBackgroundColor + 4278322180 + UIBackgroundModes + + audio + + UIFileSharingEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Nuvio/NuvioRelease.entitlements b/ios/Nuvio/NuvioRelease.entitlements index a0bc443..903def2 100644 --- a/ios/Nuvio/NuvioRelease.entitlements +++ b/ios/Nuvio/NuvioRelease.entitlements @@ -1,10 +1,8 @@ - - aps-environment - development - com.apple.developer.associated-domains - - - \ No newline at end of file + + aps-environment + development + + diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 9d062c2..40b4251 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -240,6 +240,44 @@ const ContinueWatchingSection = React.forwardRef((props, re } }, []); + // Helper function to find the next episode + const findNextEpisode = useCallback((currentSeason: number, currentEpisode: number, videos: any[]) => { + if (!videos || !Array.isArray(videos)) return null; + + // Sort videos to ensure correct order + const sortedVideos = [...videos].sort((a, b) => { + if (a.season !== b.season) return a.season - b.season; + return a.episode - b.episode; + }); + + // Strategy 1: Look for next episode in the same season + let nextEp = sortedVideos.find(v => v.season === currentSeason && v.episode === currentEpisode + 1); + + // Strategy 2: If not found, look for the first episode of the next season + if (!nextEp) { + nextEp = sortedVideos.find(v => v.season === currentSeason + 1 && v.episode === 1); + } + + // Strategy 3: Just find the very next video in the list after the current one + // This handles cases where episode numbering isn't sequential or S+1 E1 isn't the standard start + if (!nextEp) { + const currentIndex = sortedVideos.findIndex(v => v.season === currentSeason && v.episode === currentEpisode); + if (currentIndex !== -1 && currentIndex + 1 < sortedVideos.length) { + const candidate = sortedVideos[currentIndex + 1]; + // Ensure we didn't just jump to a random special; check reasonable bounds if needed, + // but generally taking the next sorted item is correct for sequential viewing. + nextEp = candidate; + } + } + + // Verify the found episode is released + if (nextEp && isEpisodeReleased(nextEp)) { + return nextEp; + } + + return null; + }, []); + // Modified loadContinueWatching to render incrementally const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => { if (isRefreshingRef.current) { @@ -432,42 +470,42 @@ const ContinueWatchingSection = React.forwardRef((props, re const { episodeId, progress, progressPercent } = episode; if (group.type === 'series' && progressPercent >= 85) { - let nextSeason: number | undefined; - let nextEpisode: number | undefined; + // Local progress completion check if (episodeId) { + let currentSeason: number | undefined; + let currentEpisode: number | undefined; + const match = episodeId.match(/s(\d+)e(\d+)/i); if (match) { - const currentSeason = parseInt(match[1], 10); - const currentEpisode = parseInt(match[2], 10); - nextSeason = currentSeason; - nextEpisode = currentEpisode + 1; + currentSeason = parseInt(match[1], 10); + currentEpisode = parseInt(match[2], 10); } else { const parts = episodeId.split(':'); if (parts.length >= 2) { const seasonNum = parseInt(parts[parts.length - 2], 10); const episodeNum = parseInt(parts[parts.length - 1], 10); if (!isNaN(seasonNum) && !isNaN(episodeNum)) { - nextSeason = seasonNum; - nextEpisode = episodeNum + 1; + currentSeason = seasonNum; + currentEpisode = episodeNum; } } } - } - if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) { - const nextEpisodeVideo = metadata.videos.find((video: any) => - video.season === nextSeason && video.episode === nextEpisode - ); - if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { - batch.push({ - ...basicContent, - id: group.id, - type: group.type, - progress: 0, - lastUpdated: progress.lastUpdated, - season: nextSeason, - episode: nextEpisode, - episodeTitle: `Episode ${nextEpisode}`, - } as ContinueWatchingItem); + + if (currentSeason !== undefined && currentEpisode !== undefined && metadata?.videos) { + const nextEpisodeVideo = findNextEpisode(currentSeason, currentEpisode, metadata.videos); + + if (nextEpisodeVideo) { + batch.push({ + ...basicContent, + id: group.id, + type: group.type, + progress: 0, + lastUpdated: progress.lastUpdated, + season: nextEpisodeVideo.season, + episode: nextEpisodeVideo.episode, + episodeTitle: `Episode ${nextEpisodeVideo.episode}`, + } as ContinueWatchingItem); + } } } continue; @@ -532,23 +570,18 @@ const ContinueWatchingSection = React.forwardRef((props, re // If watched on Trakt, treat it as completed (try to find next episode) if (isWatchedOnTrakt) { - let nextSeason = season; - let nextEpisode = (episodeNumber || 0) + 1; - - if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) { - const nextEpisodeVideo = metadata.videos.find((video: any) => - video.season === nextSeason && video.episode === nextEpisode - ); - if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { + if (season !== undefined && episodeNumber !== undefined && metadata?.videos) { + const nextEpisodeVideo = findNextEpisode(season, episodeNumber, metadata.videos); + if (nextEpisodeVideo) { batch.push({ ...basicContent, id: group.id, type: group.type, progress: 0, lastUpdated: progress.lastUpdated, - season: nextSeason, - episode: nextEpisode, - episodeTitle: `Episode ${nextEpisode}`, + season: nextEpisodeVideo.season, + episode: nextEpisodeVideo.episode, + episodeTitle: `Episode ${nextEpisodeVideo.episode}`, } as ContinueWatchingItem); } } @@ -614,28 +647,25 @@ const ContinueWatchingSection = React.forwardRef((props, re continue; } - const nextEpisode = info.episode + 1; const cachedData = await getCachedMetadata('series', showId); if (!cachedData?.basicContent) continue; const { metadata, basicContent } = cachedData; - let nextEpisodeVideo = null; - if (metadata?.videos && Array.isArray(metadata.videos)) { - nextEpisodeVideo = metadata.videos.find((video: any) => - video.season === info.season && video.episode === nextEpisode - ); - } - if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { - logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`); - traktBatch.push({ - ...basicContent, - id: showId, - type: 'series', - progress: 0, - lastUpdated: info.watchedAt, - season: info.season, - episode: nextEpisode, - episodeTitle: `Episode ${nextEpisode}`, - } as ContinueWatchingItem); + + if (metadata?.videos) { + const nextEpisodeVideo = findNextEpisode(info.season, info.episode, metadata.videos); + if (nextEpisodeVideo) { + logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${nextEpisodeVideo.season}E${nextEpisodeVideo.episode}`); + traktBatch.push({ + ...basicContent, + id: showId, + type: 'series', + progress: 0, + lastUpdated: info.watchedAt, + season: nextEpisodeVideo.season, + episode: nextEpisodeVideo.episode, + episodeTitle: `Episode ${nextEpisodeVideo.episode}`, + } as ContinueWatchingItem); + } } // Persist "watched" progress for the episode that Trakt reported (only if not recently removed)