From 486ea63a8a9c356b36de04a37ba9d54e5aefef0e Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 4 Jan 2026 14:33:16 +0530 Subject: [PATCH 01/14] fixing exo crash and some UI flaws --- .../player/controls/PlayerControls.tsx | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index 444666c..2a78f05 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -115,6 +115,13 @@ export const PlayerControls: React.FC = ({ /* Animations - State & Refs */ const [showBackwardSign, setShowBackwardSign] = React.useState(false); const [showForwardSign, setShowForwardSign] = React.useState(false); + const [previewTime, setPreviewTime] = React.useState(currentTime); + const isSlidingRef = React.useRef(false); + React.useEffect(() => { + if (!isSlidingRef.current) { + setPreviewTime(currentTime); + } + }, [currentTime]); /* Separate Animations for Each Button */ const backwardPressAnim = React.useRef(new Animated.Value(0)).current; @@ -280,10 +287,22 @@ export const PlayerControls: React.FC = ({ }} minimumValue={0} maximumValue={duration || 1} - value={currentTime} - onValueChange={onSliderValueChange} - onSlidingStart={onSlidingStart} - onSlidingComplete={onSlidingComplete} + + value={previewTime} + + onValueChange={(v) => setPreviewTime(v)} + + onSlidingStart={() => { + isSlidingRef.current = true; + onSlidingStart(); + }} + + onSlidingComplete={(v) => { + isSlidingRef.current = false; + setPreviewTime(v); + onSlidingComplete(v); + }} + minimumTrackTintColor={currentTheme.colors.primary} maximumTrackTintColor={currentTheme.colors.mediumEmphasis} thumbTintColor={Platform.OS === 'android' ? currentTheme.colors.white : undefined} @@ -608,4 +627,4 @@ export const PlayerControls: React.FC = ({ ); }; -export default PlayerControls; \ No newline at end of file +export default PlayerControls; From 5bd9f4110443122808ed134da43c97c092c61b32 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 4 Jan 2026 14:36:50 +0530 Subject: [PATCH 02/14] decreasing player refresh time from 4 times per second to 2 times , to prevent crashes with heavy files --- .../main/java/com/brentvatne/exoplayer/ReactExoplayerView.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 44d8be2..049ef69 100644 --- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -244,7 +244,7 @@ public class ReactExoplayerView extends FrameLayout implements private BufferingStrategy.BufferingStrategyEnum bufferingStrategy; private boolean disableDisconnectError; private boolean preventsDisplaySleepDuringVideoPlayback = true; - private float mProgressUpdateInterval = 250.0f; + private float mProgressUpdateInterval = 500.0f; protected boolean playInBackground = false; private boolean mReportBandwidth = false; private boolean controls = false; From 4fdda9a1848cddc6f4552493cdba6b208990034b Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 4 Jan 2026 15:25:27 +0530 Subject: [PATCH 03/14] several exoplayer optimizations to prevent crashes with huge file sizes --- .../exoplayer/ReactExoplayerView.java | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 049ef69..cb6f869 100644 --- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -161,7 +161,7 @@ public class ReactExoplayerView extends FrameLayout implements AdEvent.AdEventListener, AdErrorEvent.AdErrorListener { - public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 1; + public static final double DEFAULT_MAX_HEAP_ALLOCATION_PERCENT = 0.5; public static final double DEFAULT_MIN_BUFFER_MEMORY_RESERVE = 0; private static final String TAG = "ReactExoplayerView"; @@ -244,7 +244,7 @@ public class ReactExoplayerView extends FrameLayout implements private BufferingStrategy.BufferingStrategyEnum bufferingStrategy; private boolean disableDisconnectError; private boolean preventsDisplaySleepDuringVideoPlayback = true; - private float mProgressUpdateInterval = 500.0f; + private float mProgressUpdateInterval = 1000.0f; protected boolean playInBackground = false; private boolean mReportBandwidth = false; private boolean controls = false; @@ -642,6 +642,8 @@ public class ReactExoplayerView extends FrameLayout implements } private void initializePlayer() { + drmRetryCount = 0; + hasDrmFailed = false; disableCache = ReactNativeVideoManager.Companion.getInstance().shouldDisableCache(source); ReactExoplayerView self = this; @@ -664,10 +666,14 @@ public class ReactExoplayerView extends FrameLayout implements PictureInPictureUtil.applyAutoEnterEnabled(themedReactContext, pictureInPictureParamsBuilder, this.enterPictureInPictureOnLeave); } - if (!source.isLocalAssetFile() && !source.isAsset() && source.getBufferConfig().getCacheSize() > 0) { + long requestedCacheSize = source.getBufferConfig().getCacheSize(); + long MAX_SAFE_CACHE_SIZE = 100L * 1024 * 1024; + long effectiveCacheSize = Math.min(requestedCacheSize, MAX_SAFE_CACHE_SIZE); + if (!source.isLocalAssetFile() && !source.isAsset() && effectiveCacheSize > 0) { RNVSimpleCache.INSTANCE.setSimpleCache( this.getContext(), - source.getBufferConfig().getCacheSize()); + effectiveCacheSize + ); useCache = true; } else { useCache = false; @@ -876,13 +882,10 @@ public class ReactExoplayerView extends FrameLayout implements MediaSource mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, videoSource); // wait for player to be set - while (player == null) { - try { - wait(); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - DebugLog.e(TAG, ex.toString()); - } + if (player == null) { + DebugLog.w(TAG, "Player not ready yet, aborting source initialization"); + playerNeedsSource = true; + return; } boolean haveResumePosition = resumeWindow != C.INDEX_UNSET; @@ -1993,13 +1996,15 @@ public class ReactExoplayerView extends FrameLayout implements if (!hasDrmFailed) { // When DRM fails to reach the app level certificate server it will fail with a // source error so we assume that it is DRM related and try one more time - hasDrmFailed = true; - playerNeedsSource = true; - updateResumePosition(); - initializePlayer(); - setPlayWhenReady(true); - return; - } + if (drmRetryCount < 1) { + drmRetryCount++; + hasDrmFailed = true; + playerNeedsSource = true; + updateResumePosition(); + initializePlayer(); + setPlayWhenReady(true); + return; + } break; default: break; From 4aa22cc1c3dd4e4684ec14c32538920a1ec3acd3 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 Jan 2026 15:37:49 +0530 Subject: [PATCH 04/14] chore: improved tmdb enrichment logic --- .../home/ContinueWatchingSection.tsx | 101 +++-- src/contexts/TraktContext.tsx | 27 +- src/hooks/useMetadata.ts | 57 +-- src/hooks/useSettings.ts | 2 + src/screens/TMDBSettingsScreen.tsx | 17 + src/screens/TraktSettingsScreen.tsx | 106 ++++- src/services/tmdbService.ts | 408 ++++++++---------- src/services/traktService.ts | 38 +- 8 files changed, 413 insertions(+), 343 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index ac006e9..e5a7d09 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -226,7 +226,7 @@ const ContinueWatchingSection = React.forwardRef((props, re try { const shouldFetchMeta = await stremioService.isValidContentId(type, id); - + const [metadata, basicContent, addonSpecificMeta] = await Promise.all([ shouldFetchMeta ? stremioService.getMetaDetails(type, id) : Promise.resolve(null), catalogService.getBasicContentDetails(type, id), @@ -237,7 +237,7 @@ const ContinueWatchingSection = React.forwardRef((props, re ]); const preferredAddonMeta = addonSpecificMeta || metadata; - + const finalContent = basicContent ? { ...basicContent, @@ -245,7 +245,7 @@ const ContinueWatchingSection = React.forwardRef((props, re ...(preferredAddonMeta?.poster && { poster: preferredAddonMeta.poster }), ...(preferredAddonMeta?.description && { description: preferredAddonMeta.description }), } : null; - + if (finalContent) { const result = { @@ -263,11 +263,11 @@ const ContinueWatchingSection = React.forwardRef((props, re return null; } }, []); - + const findNextEpisode = useCallback(( - currentSeason: number, - currentEpisode: number, + currentSeason: number, + currentEpisode: number, videos: any[], watchedSet?: Set, showId?: string @@ -282,16 +282,16 @@ const ContinueWatchingSection = React.forwardRef((props, re const isAlreadyWatched = (season: number, episode: number): boolean => { if (!watchedSet || !showId) return false; const cleanShowId = showId.startsWith('tt') ? showId : `tt${showId}`; - return watchedSet.has(`${cleanShowId}:${season}:${episode}`) || - watchedSet.has(`${showId}:${season}:${episode}`); + return watchedSet.has(`${cleanShowId}:${season}:${episode}`) || + watchedSet.has(`${showId}:${season}:${episode}`); }; for (const video of sortedVideos) { if (video.season < currentSeason) continue; if (video.season === currentSeason && video.episode <= currentEpisode) continue; - + if (isAlreadyWatched(video.season, video.episode)) continue; - + if (isEpisodeReleased(video)) { return video; } @@ -299,7 +299,7 @@ const ContinueWatchingSection = React.forwardRef((props, re return null; }, []); - + // Modified loadContinueWatching to render incrementally const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => { @@ -379,7 +379,7 @@ const ContinueWatchingSection = React.forwardRef((props, re const progressPercent = progress.duration > 0 ? (progress.currentTime / progress.duration) * 100 - : 0; + : 0; // Skip fully watched movies if (type === 'movie' && progressPercent >= 85) continue; // Skip movies with no actual progress (ensure > 0%) @@ -533,7 +533,7 @@ const ContinueWatchingSection = React.forwardRef((props, re } } } catch { - + } if (!nextEpisode && metadata?.videos) { @@ -558,7 +558,7 @@ const ContinueWatchingSection = React.forwardRef((props, re } as ContinueWatchingItem); } } - + } continue; } @@ -711,7 +711,7 @@ const ContinueWatchingSection = React.forwardRef((props, re const movieKey = `movie:${imdbId}`; if (recentlyRemovedRef.current.has(movieKey)) continue; - const cachedData = await getCachedMetadata('movie', imdbId, item.addonId); + const cachedData = await getCachedMetadata('movie', imdbId); if (!cachedData?.basicContent) continue; const pausedAt = new Date(item.paused_at).getTime(); @@ -743,7 +743,7 @@ const ContinueWatchingSection = React.forwardRef((props, re continue; } - const cachedData = await getCachedMetadata('series', showImdb, item.addonId); + const cachedData = await getCachedMetadata('series', showImdb); if (!cachedData?.basicContent) continue; traktBatch.push({ @@ -1204,41 +1204,40 @@ const ContinueWatchingSection = React.forwardRef((props, re padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12 } ]}> - {(() => { - const isUpNext = item.type === 'series' && item.progress === 0; - return ( - - - {item.name} - - {isUpNext && ( - - Up Next - - )} - - ); - })()} - + {(() => { + const isUpNext = item.type === 'series' && item.progress === 0; + return ( + + + {item.name} + + {isUpNext && ( + + Up Next + + )} + + ); + })()} {/* Episode Info or Year */} {(() => { diff --git a/src/contexts/TraktContext.tsx b/src/contexts/TraktContext.tsx index 0f8c181..a30ff91 100644 --- a/src/contexts/TraktContext.tsx +++ b/src/contexts/TraktContext.tsx @@ -1,12 +1,13 @@ import React, { createContext, useContext, ReactNode } from 'react'; import { useTraktIntegration } from '../hooks/useTraktIntegration'; -import { - TraktUser, - TraktWatchedItem, - TraktWatchlistItem, - TraktCollectionItem, +import { + TraktUser, + TraktWatchedItem, + TraktWatchlistItem, + TraktCollectionItem, TraktRatingItem, - TraktPlaybackItem + TraktPlaybackItem, + traktService } from '../services/traktService'; interface TraktContextProps { @@ -37,15 +38,25 @@ interface TraktContextProps { removeFromCollection: (imdbId: string, type: 'movie' | 'show') => Promise; isInWatchlist: (imdbId: string, type: 'movie' | 'show') => boolean; isInCollection: (imdbId: string, type: 'movie' | 'show') => boolean; + // Maintenance mode + isMaintenanceMode: boolean; + maintenanceMessage: string; } const TraktContext = createContext(undefined); export function TraktProvider({ children }: { children: ReactNode }) { const traktIntegration = useTraktIntegration(); - + + // Add maintenance mode values to the context + const contextValue: TraktContextProps = { + ...traktIntegration, + isMaintenanceMode: traktService.isMaintenanceMode(), + maintenanceMessage: traktService.getMaintenanceMessage(), + }; + return ( - + {children} ); diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 17ad38c..ce27c61 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -550,7 +550,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId); const movieDetails = await tmdbService.getMovieDetails( tmdbId, - settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US' + settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en' ); if (movieDetails) { const imdbId = movieDetails.imdb_id || movieDetails.external_ids?.imdb_id; @@ -634,7 +634,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat try { const showDetails = await tmdbService.getTVShowDetails( parseInt(tmdbId), - settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US' + settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en' ); if (showDetails) { // OPTIMIZATION: Fetch external IDs, credits, and logo in parallel @@ -824,9 +824,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Store addon logo before TMDB enrichment overwrites it const addonLogo = (finalMetadata as any).logo; - // If localization is enabled, merge TMDB localized text (name/overview) before first render + // If localization is enabled AND title/description enrichment is enabled, merge TMDB localized text (name/overview) before first render try { - if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) { + if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata && settings.tmdbEnrichTitleDescription) { const tmdbSvc = TMDBService.getInstance(); let finalTmdbId: number | null = tmdbId; if (!finalTmdbId) { @@ -857,8 +857,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat finalMetadata = { ...finalMetadata, - name: finalMetadata.name || localized.title, - description: finalMetadata.description || localized.overview, + name: localized.title || finalMetadata.name, + description: localized.overview || finalMetadata.description, movieDetails: movieDetailsObj, ...(productionInfo.length > 0 && { networks: productionInfo }), }; @@ -894,8 +894,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat finalMetadata = { ...finalMetadata, - name: finalMetadata.name || localized.name, - description: finalMetadata.description || localized.overview, + name: localized.name || finalMetadata.name, + description: localized.overview || finalMetadata.description, tvDetails, ...(productionInfo.length > 0 && { networks: productionInfo }), }; @@ -909,14 +909,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Centralized logo fetching logic try { - if (addonLogo) { - finalMetadata.logo = addonLogo; - if (__DEV__) { - console.log('[useMetadata] Using addon-provided logo:', { hasLogo: true }); - } - // Check both master switch AND granular logos setting - } else if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) { - // Only use TMDB logos when both enrichment AND logos option are ON + // When TMDB enrichment AND logos are enabled, prioritize TMDB logo over addon logo + if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) { const tmdbService = TMDBService.getInstance(); const preferredLanguage = settings.tmdbLanguagePreference || 'en'; const contentType = type === 'series' ? 'tv' : 'movie'; @@ -932,23 +926,26 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat if (tmdbIdForLogo) { const logoUrl = await tmdbService.getContentLogo(contentType, tmdbIdForLogo, preferredLanguage); - finalMetadata.logo = logoUrl || undefined; // TMDB logo or undefined (no addon fallback) + // Use TMDB logo if found, otherwise fall back to addon logo + finalMetadata.logo = logoUrl || addonLogo || undefined; if (__DEV__) { console.log('[useMetadata] Logo fetch result:', { contentType, tmdbIdForLogo, preferredLanguage, - logoUrl: !!logoUrl, + tmdbLogoFound: !!logoUrl, + usingAddonFallback: !logoUrl && !!addonLogo, enrichmentEnabled: true }); } } else { - finalMetadata.logo = undefined; // No TMDB ID means no logo - if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, will show text title'); + // No TMDB ID, fall back to addon logo + finalMetadata.logo = addonLogo || undefined; + if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, using addon logo'); } } else { - // When enrichment or logos is OFF, keep addon logo or undefined - finalMetadata.logo = finalMetadata.logo || undefined; + // When enrichment or logos is OFF, use addon logo + finalMetadata.logo = addonLogo || finalMetadata.logo || undefined; if (__DEV__) { console.log('[useMetadata] TMDB logo enrichment disabled, using addon logo:', { hasAddonLogo: !!finalMetadata.logo, @@ -1125,10 +1122,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Fetch season posters from TMDB only if enrichment AND season posters are enabled if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichSeasonPosters) { try { + const lang = settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}` : 'en'; const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null); if (tmdbIdToUse) { if (!tmdbId) setTmdbId(tmdbIdToUse); - const showDetails = await tmdbService.getTVShowDetails(tmdbIdToUse); + const showDetails = await tmdbService.getTVShowDetails(tmdbIdToUse, lang); if (showDetails?.seasons) { Object.keys(groupedAddonEpisodes).forEach(seasonStr => { const seasonNum = parseInt(seasonStr, 10); @@ -1156,7 +1154,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat try { const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null); if (tmdbIdToUse) { - const lang = `${settings.tmdbLanguagePreference || 'en'}-US`; + // Use just the language code (e.g., 'ar', not 'ar-US') for TMDB API + const lang = settings.tmdbLanguagePreference || 'en'; const seasons = Object.keys(groupedAddonEpisodes).map(Number); for (const seasonNum of seasons) { const seasonEps = groupedAddonEpisodes[seasonNum]; @@ -1264,13 +1263,14 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Fallback to TMDB if no addon episodes logger.log('πŸ“Ί No addon episodes found, falling back to TMDB'); + const lang = settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}` : 'en'; const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id); if (tmdbIdResult) { setTmdbId(tmdbIdResult); const [allEpisodes, showDetails] = await Promise.all([ - tmdbService.getAllEpisodes(tmdbIdResult), - tmdbService.getTVShowDetails(tmdbIdResult) + tmdbService.getAllEpisodes(tmdbIdResult, lang), + tmdbService.getTVShowDetails(tmdbIdResult, lang) ]); const transformedEpisodes: GroupedEpisodes = {}; @@ -2038,7 +2038,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setLoadingRecommendations(true); try { const tmdbService = TMDBService.getInstance(); - const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId)); + const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; + const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId), lang); // Convert TMDB results to StreamingContent format (simplified) const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({ @@ -2056,7 +2057,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } finally { setLoadingRecommendations(false); } - }, [tmdbId, type]); + }, [tmdbId, type, settings.useTmdbLocalizedMetadata, settings.tmdbLanguagePreference]); // Fetch TMDB ID if needed and then recommendations useEffect(() => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 0e2faad..4165cc4 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -92,6 +92,7 @@ export interface AppSettings { tmdbEnrichMovieDetails: boolean; // Show movie details (budget, revenue, tagline, etc.) tmdbEnrichTvDetails: boolean; // Show TV details (status, seasons count, networks, etc.) tmdbEnrichCollections: boolean; // Show movie collections/franchises + tmdbEnrichTitleDescription: boolean; // Use TMDB title/description (overrides addon when localization enabled) // Trakt integration showTraktComments: boolean; // Show Trakt comments in metadata screens // Continue Watching behavior @@ -176,6 +177,7 @@ export const DEFAULT_SETTINGS: AppSettings = { tmdbEnrichMovieDetails: true, tmdbEnrichTvDetails: true, tmdbEnrichCollections: true, + tmdbEnrichTitleDescription: true, // Enabled by default for backward compatibility // Trakt integration showTraktComments: true, // Show Trakt comments by default when authenticated // Continue Watching behavior diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index 62c21c1..398bf2d 100644 --- a/src/screens/TMDBSettingsScreen.tsx +++ b/src/screens/TMDBSettingsScreen.tsx @@ -677,6 +677,23 @@ const TMDBSettingsScreen = () => { /> + {/* Title & Description */} + + + Title & Description + + Use TMDb localized title and overview text + + + updateSetting('tmdbEnrichTitleDescription', v)} + trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }} + thumbColor={Platform.OS === 'android' ? currentTheme.colors.white : ''} + ios_backgroundColor={'rgba(255,255,255,0.1)'} + /> + + {/* Title Logos */} diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index 74acf7c..dff9688 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -53,7 +53,7 @@ const TraktSettingsScreen: React.FC = () => { const [isAuthenticated, setIsAuthenticated] = useState(false); const [userProfile, setUserProfile] = useState(null); const { currentTheme } = useTheme(); - + const { settings: autosyncSettings, isSyncing, @@ -101,7 +101,7 @@ const TraktSettingsScreen: React.FC = () => { try { const authenticated = await traktService.isAuthenticated(); setIsAuthenticated(authenticated); - + if (authenticated) { const profile = await traktService.getUserProfile(); setUserProfile(profile); @@ -151,8 +151,8 @@ const TraktSettingsScreen: React.FC = () => { 'Successfully Connected', 'Your Trakt account has been connected successfully.', [ - { - label: 'OK', + { + label: 'OK', onPress: () => navigation.goBack(), } ] @@ -190,9 +190,9 @@ const TraktSettingsScreen: React.FC = () => { 'Sign Out', 'Are you sure you want to sign out of your Trakt account?', [ - { label: 'Cancel', onPress: () => {} }, - { - label: 'Sign Out', + { label: 'Cancel', onPress: () => { } }, + { + label: 'Sign Out', onPress: async () => { setIsLoading(true); try { @@ -224,26 +224,39 @@ const TraktSettingsScreen: React.FC = () => { onPress={() => navigation.goBack()} style={styles.backButton} > - Settings - + {/* Empty for now, but ready for future actions */} - + Trakt Settings - + + + Under Maintenance + + {traktService.getMaintenanceMessage()} + + + + )} + + @@ -255,12 +268,44 @@ const TraktSettingsScreen: React.FC = () => { + ) : traktService.isMaintenanceMode() ? ( + + + + Trakt Unavailable + + + The Trakt integration is temporarily paused for maintenance. All syncing and authentication is disabled until maintenance is complete. + + + + + Service Under Maintenance + + + ) : isAuthenticated && userProfile ? ( {userProfile.avatar ? ( - @@ -315,7 +360,7 @@ const TraktSettingsScreen: React.FC = () => { ) : ( - { )} - + (key: string): Promise { - if (!USE_REMOTE_CACHE || !REMOTE_CACHE_URL) return null; - try { - const url = `${REMOTE_CACHE_URL}/cache/${REMOTE_CACHE_NAMESPACE}/${encodeURIComponent(key)}`; - const response = await axios.get(url, { headers: { 'Content-Type': 'application/json' } }); - const payload = response.data; - if (payload && Object.prototype.hasOwnProperty.call(payload, 'data')) { - // Warm local cache for faster subsequent reads (skip if disabled) - if (!DISABLE_LOCAL_CACHE) { - this.setCachedData(key, payload.data); - } - logger.log(`[TMDB Remote Cache] βœ… HIT: ${key}`); - return payload.data as T; - } - return null; - } catch (_) { - logger.log(`[TMDB Remote Cache] ❌ MISS: ${key}`); - return null; - } - } - - private async remoteSetCachedData(key: string, data: any): Promise { - if (!USE_REMOTE_CACHE || !REMOTE_CACHE_URL) return; - try { - const url = `${REMOTE_CACHE_URL}/cache/${REMOTE_CACHE_NAMESPACE}/${encodeURIComponent(key)}`; - await axios.put(url, { data, ttlMs: CACHE_TTL_MS }, { headers: { 'Content-Type': 'application/json' } }); - logger.log(`[TMDB Remote Cache] πŸ’Ύ STORED: ${key}`); - } catch (_) { - // best-effort only - } - } - - private async remoteClearAllCache(): Promise { - if (!USE_REMOTE_CACHE || !REMOTE_CACHE_URL) return; - try { - const url = `${REMOTE_CACHE_URL}/cache/${REMOTE_CACHE_NAMESPACE}/clear`; - await axios.post(url, {}, { headers: { 'Content-Type': 'application/json' } }); - logger.log(`[TMDB Remote Cache] πŸ—‘οΈ CLEARED namespace ${REMOTE_CACHE_NAMESPACE}`); - } catch (_) { - // ignore - } - } - /** * Generate a unique cache key from endpoint and parameters */ @@ -206,10 +154,6 @@ export class TMDBService { * Retrieve cached data if not expired */ private getCachedData(key: string): T | null { - if (DISABLE_LOCAL_CACHE) { - logger.log(`[TMDB Cache] 🚫 LOCAL DISABLED: ${key}`); - return null; - } try { const cachedStr = mmkvStorage.getString(key); if (!cachedStr) { @@ -236,17 +180,11 @@ export class TMDBService { } } + /** + * Get from local cache + */ private async getFromCacheOrRemote(key: string): Promise { - // Local-first: serve from MMKV if present; else try remote and warm local - if (!DISABLE_LOCAL_CACHE) { - const local = this.getCachedData(key); - if (local !== null) return local; - } - if (USE_REMOTE_CACHE && REMOTE_CACHE_URL) { - const remote = await this.remoteGetCachedData(key); - if (remote !== null) return remote; - } - return null; + return this.getCachedData(key); } /** @@ -260,21 +198,14 @@ export class TMDBService { if (data === null || data === undefined) { return; } - + try { - if (!DISABLE_LOCAL_CACHE) { const cacheEntry = { data, timestamp: Date.now() }; mmkvStorage.setString(key, JSON.stringify(cacheEntry)); logger.log(`[TMDB Cache] πŸ’Ύ STORED: ${key}`); - } else { - logger.log(`[TMDB Cache] β›” LOCAL WRITE SKIPPED: ${key}`); - } - // Best-effort remote write - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.remoteSetCachedData(key, data); } catch (error) { // Ignore cache errors } @@ -312,15 +243,15 @@ export class TMDBService { mmkvStorage.getItem(TMDB_API_KEY_STORAGE_KEY), mmkvStorage.getItem(USE_CUSTOM_TMDB_API_KEY) ]); - + this.useCustomKey = savedUseCustomKey === 'true'; - + if (this.useCustomKey && savedKey) { this.apiKey = savedKey; } else { this.apiKey = DEFAULT_API_KEY; } - + this.apiKeyLoaded = true; } catch (error) { this.apiKey = DEFAULT_API_KEY; @@ -333,7 +264,7 @@ export class TMDBService { if (!this.apiKeyLoaded) { await this.loadApiKey(); } - + return { 'Content-Type': 'application/json', }; @@ -344,7 +275,7 @@ export class TMDBService { if (!this.apiKeyLoaded) { await this.loadApiKey(); } - + return { api_key: this.apiKey, ...additionalParams @@ -358,9 +289,9 @@ export class TMDBService { /** * Search for a TV show by name */ - async searchTVShow(query: string): Promise { - const cacheKey = this.generateCacheKey('search_tv', { query }); - + async searchTVShow(query: string, language: string = 'en-US'): Promise { + const cacheKey = this.generateCacheKey('search_tv', { query, language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -372,7 +303,7 @@ export class TMDBService { params: await this.getParams({ query, include_adult: false, - language: 'en-US', + language, page: 1, }), }); @@ -389,7 +320,7 @@ export class TMDBService { */ async getTVShowDetails(tmdbId: number, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`tv_${tmdbId}`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -419,7 +350,7 @@ export class TMDBService { episodeNumber: number ): Promise<{ imdb_id: string | null } | null> { const cacheKey = this.generateCacheKey(`tv_${tmdbId}_season_${seasonNumber}_episode_${episodeNumber}_external_ids`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote<{ imdb_id: string | null }>(cacheKey); if (cached !== null) return cached; @@ -446,7 +377,7 @@ export class TMDBService { */ async getIMDbRating(showName: string, seasonNumber: number, episodeNumber: number): Promise { const cacheKey = this.generateRatingCacheKey(showName, seasonNumber, episodeNumber); - + // Check cache first if (TMDBService.ratingCache.has(cacheKey)) { return TMDBService.ratingCache.get(cacheKey) ?? null; @@ -462,7 +393,7 @@ export class TMDBService { Episode: episodeNumber } }); - + let rating: number | null = null; if (response.data && response.data.imdbRating && response.data.imdbRating !== 'N/A') { rating = parseFloat(response.data.imdbRating); @@ -484,14 +415,14 @@ export class TMDBService { */ async getIMDbRatings(tmdbId: number): Promise { const IMDB_RATINGS_API_BASE_URL = process.env.EXPO_PUBLIC_IMDB_RATINGS_API_BASE_URL; - + if (!IMDB_RATINGS_API_BASE_URL) { logger.error('[TMDB API] Missing EXPO_PUBLIC_IMDB_RATINGS_API_BASE_URL environment variable'); return null; } const cacheKey = this.generateCacheKey(`imdb_ratings_${tmdbId}`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -505,13 +436,13 @@ export class TMDBService { 'Content-Type': 'application/json', }, }); - + const data = response.data; if (data && Array.isArray(data)) { this.setCachedData(cacheKey, data); return data; } - + return null; } catch (error) { logger.error('[TMDB API] Error fetching IMDb ratings:', error); @@ -525,7 +456,7 @@ export class TMDBService { */ async getSeasonDetails(tmdbId: number, seasonNumber: number, showName?: string, language: string = 'en-US'): Promise { const cacheKey = this.generateCacheKey(`tv_${tmdbId}_season_${seasonNumber}`, { language, showName }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -556,7 +487,7 @@ export class TMDBService { language: string = 'en-US' ): Promise { const cacheKey = this.generateCacheKey(`tv_${tmdbId}_season_${seasonNumber}_episode_${episodeNumber}`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -589,7 +520,7 @@ export class TMDBService { try { // Extract the base IMDB ID (remove season/episode info if present) const imdbId = stremioId.split(':')[0]; - + // Use the existing findTMDBIdByIMDB function to get the TMDB ID const tmdbId = await this.findTMDBIdByIMDB(imdbId); return tmdbId; @@ -601,9 +532,9 @@ export class TMDBService { /** * Find TMDB ID by IMDB ID */ - async findTMDBIdByIMDB(imdbId: string): Promise { - const cacheKey = this.generateCacheKey('find_imdb', { imdbId }); - + async findTMDBIdByIMDB(imdbId: string, language: string = 'en-US'): Promise { + const cacheKey = this.generateCacheKey('find_imdb', { imdbId, language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -611,31 +542,31 @@ export class TMDBService { try { // Extract the IMDB ID without season/episode info const baseImdbId = imdbId.split(':')[0]; - + const response = await axios.get(`${BASE_URL}/find/${baseImdbId}`, { headers: await this.getHeaders(), params: await this.getParams({ external_source: 'imdb_id', - language: 'en-US', + language, }), }); - + let result: number | null = null; - + // Check TV results first if (response.data.tv_results && response.data.tv_results.length > 0) { result = response.data.tv_results[0].id; } - + // Check movie results as fallback if (!result && response.data.movie_results && response.data.movie_results.length > 0) { result = response.data.movie_results[0].id; } - + if (result !== null) { this.setCachedData(cacheKey, result); } - + return result; } catch (error) { return null; @@ -649,34 +580,35 @@ export class TMDBService { if (!path) { return null; } - + const baseImageUrl = 'https://image.tmdb.org/t/p/'; const fullUrl = `${baseImageUrl}${size}${path}`; - + return fullUrl; } /** * Get all episodes for a TV show + * @param language Language for localized episode names/overviews */ - async getAllEpisodes(tmdbId: number): Promise<{ [seasonNumber: number]: TMDBEpisode[] }> { + async getAllEpisodes(tmdbId: number, language: string = 'en-US'): Promise<{ [seasonNumber: number]: TMDBEpisode[] }> { try { // First get the show details to know how many seasons there are - const showDetails = await this.getTVShowDetails(tmdbId); + const showDetails = await this.getTVShowDetails(tmdbId, language); if (!showDetails) return {}; const allEpisodes: { [seasonNumber: number]: TMDBEpisode[] } = {}; - + // Get episodes for each season (in parallel) const seasonPromises = showDetails.seasons .filter(season => season.season_number > 0) // Filter out specials (season 0) .map(async season => { - const seasonDetails = await this.getSeasonDetails(tmdbId, season.season_number); + const seasonDetails = await this.getSeasonDetails(tmdbId, season.season_number, showDetails.name, language); if (seasonDetails && seasonDetails.episodes) { allEpisodes[season.season_number] = seasonDetails.episodes; } }); - + await Promise.all(seasonPromises); return allEpisodes; } catch (error) { @@ -692,7 +624,7 @@ export class TMDBService { if (episode.still_path) { return this.getImageUrl(episode.still_path, size); } - + // Try season poster as fallback if (show && show.seasons) { const season = show.seasons.find(s => s.season_number === episode.season_number); @@ -700,12 +632,12 @@ export class TMDBService { return this.getImageUrl(season.poster_path, size); } } - + // Use show poster as last resort if (show && show.poster_path) { return this.getImageUrl(show.poster_path, size); } - + return null; } @@ -714,7 +646,7 @@ export class TMDBService { */ formatAirDate(airDate: string | null): string { if (!airDate) return 'Unknown'; - + try { const date = new Date(airDate); return date.toLocaleDateString('en-US', { @@ -727,9 +659,9 @@ export class TMDBService { } } - async getCredits(tmdbId: number, type: string) { - const cacheKey = this.generateCacheKey(`${type}_${tmdbId}_credits`); - + async getCredits(tmdbId: number, type: string, language: string = 'en-US') { + const cacheKey = this.generateCacheKey(`${type}_${tmdbId}_credits`, { language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote<{ cast: any[]; crew: any[] }>(cacheKey); if (cached !== null) return cached; @@ -738,7 +670,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/${type === 'series' ? 'tv' : 'movie'}/${tmdbId}/credits`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, }), }); const data = { @@ -752,9 +684,9 @@ export class TMDBService { } } - async getPersonDetails(personId: number) { - const cacheKey = this.generateCacheKey(`person_${personId}`); - + async getPersonDetails(personId: number, language: string = 'en-US') { + const cacheKey = this.generateCacheKey(`person_${personId}`, { language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -763,7 +695,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/person/${personId}`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, }), }); const data = response.data; @@ -777,9 +709,9 @@ export class TMDBService { /** * Get person's movie credits (cast and crew) */ - async getPersonMovieCredits(personId: number) { - const cacheKey = this.generateCacheKey(`person_${personId}_movie_credits`); - + async getPersonMovieCredits(personId: number, language: string = 'en-US') { + const cacheKey = this.generateCacheKey(`person_${personId}_movie_credits`, { language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -788,7 +720,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/person/${personId}/movie_credits`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, }), }); const data = response.data; @@ -802,9 +734,9 @@ export class TMDBService { /** * Get person's TV credits (cast and crew) */ - async getPersonTvCredits(personId: number) { - const cacheKey = this.generateCacheKey(`person_${personId}_tv_credits`); - + async getPersonTvCredits(personId: number, language: string = 'en-US') { + const cacheKey = this.generateCacheKey(`person_${personId}_tv_credits`, { language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -813,7 +745,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/person/${personId}/tv_credits`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, }), }); const data = response.data; @@ -827,9 +759,9 @@ export class TMDBService { /** * Get person's combined credits (movies and TV) */ - async getPersonCombinedCredits(personId: number) { - const cacheKey = this.generateCacheKey(`person_${personId}_combined_credits`); - + async getPersonCombinedCredits(personId: number, language: string = 'en-US') { + const cacheKey = this.generateCacheKey(`person_${personId}_combined_credits`, { language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -838,7 +770,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/person/${personId}/combined_credits`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, }), }); const data = response.data; @@ -854,7 +786,7 @@ export class TMDBService { */ async getShowExternalIds(tmdbId: number): Promise<{ imdb_id: string | null } | null> { const cacheKey = this.generateCacheKey(`tv_${tmdbId}_external_ids`); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote<{ imdb_id: string | null }>(cacheKey); if (cached !== null) return cached; @@ -879,9 +811,9 @@ export class TMDBService { if (!this.apiKey) { return []; } - + const cacheKey = this.generateCacheKey(`${type}_${tmdbId}_recommendations`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -899,9 +831,9 @@ export class TMDBService { } } - async searchMulti(query: string): Promise { - const cacheKey = this.generateCacheKey('search_multi', { query }); - + async searchMulti(query: string, language: string = 'en-US'): Promise { + const cacheKey = this.generateCacheKey('search_multi', { query, language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -912,7 +844,7 @@ export class TMDBService { params: await this.getParams({ query, include_adult: false, - language: 'en-US', + language, page: 1, }), }); @@ -929,7 +861,7 @@ export class TMDBService { */ async getMovieDetails(movieId: string, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`movie_${movieId}`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -955,7 +887,7 @@ export class TMDBService { */ async getCollectionDetails(collectionId: number, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`collection_${collectionId}`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -980,7 +912,7 @@ export class TMDBService { */ async getCollectionImages(collectionId: number, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`collection_${collectionId}_images`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1006,14 +938,14 @@ export class TMDBService { */ async getMovieImagesFull(movieId: number | string, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`movie_${movieId}_images_full`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) { return cached; } - + try { const response = await axios.get(`${BASE_URL}/movie/${movieId}/images`, { headers: await this.getHeaders(), @@ -1024,7 +956,7 @@ export class TMDBService { const data = response.data; - + this.setCachedData(cacheKey, data); return data; } catch (error) { @@ -1037,7 +969,7 @@ export class TMDBService { */ async getMovieImages(movieId: number | string, preferredLanguage: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`movie_${movieId}_logo`, { preferredLanguage }); - + // Check cache const cached = this.getCachedData(cacheKey); if (cached !== null) return cached; @@ -1051,15 +983,15 @@ export class TMDBService { }); const images = response.data; - + let result: string | null = null; - + if (images && images.logos && images.logos.length > 0) { // First prioritize preferred language SVG logos if not English if (preferredLanguage !== 'en') { - const preferredSvgLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.svg') && + const preferredSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && logo.iso_639_1 === preferredLanguage ); if (preferredSvgLogo) { @@ -1068,19 +1000,19 @@ export class TMDBService { // Then preferred language PNG logos if (!result) { - const preferredPngLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.png') && + const preferredPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && logo.iso_639_1 === preferredLanguage ); if (preferredPngLogo) { result = this.getImageUrl(preferredPngLogo.file_path); } } - + // Then any preferred language logo if (!result) { - const preferredLogo = images.logos.find((logo: any) => + const preferredLogo = images.logos.find((logo: any) => logo.iso_639_1 === preferredLanguage ); if (preferredLogo) { @@ -1091,9 +1023,9 @@ export class TMDBService { // Then prioritize English SVG logos if (!result) { - const enSvgLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.svg') && + const enSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && logo.iso_639_1 === 'en' ); if (enSvgLogo) { @@ -1103,19 +1035,19 @@ export class TMDBService { // Then English PNG logos if (!result) { - const enPngLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.png') && + const enPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && logo.iso_639_1 === 'en' ); if (enPngLogo) { result = this.getImageUrl(enPngLogo.file_path); } } - + // Then any English logo if (!result) { - const enLogo = images.logos.find((logo: any) => + const enLogo = images.logos.find((logo: any) => logo.iso_639_1 === 'en' ); if (enLogo) { @@ -1125,7 +1057,7 @@ export class TMDBService { // Fallback to any SVG logo if (!result) { - const svgLogo = images.logos.find((logo: any) => + const svgLogo = images.logos.find((logo: any) => logo.file_path && logo.file_path.endsWith('.svg') ); if (svgLogo) { @@ -1135,14 +1067,14 @@ export class TMDBService { // Then any PNG logo if (!result) { - const pngLogo = images.logos.find((logo: any) => + const pngLogo = images.logos.find((logo: any) => logo.file_path && logo.file_path.endsWith('.png') ); if (pngLogo) { result = this.getImageUrl(pngLogo.file_path); } } - + // Last resort: any logo if (!result) { result = this.getImageUrl(images.logos[0].file_path); @@ -1161,7 +1093,7 @@ export class TMDBService { */ async getTvShowImagesFull(showId: number | string, language: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`tv_${showId}_images_full`, { language }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1187,7 +1119,7 @@ export class TMDBService { */ async getTvShowImages(showId: number | string, preferredLanguage: string = 'en'): Promise { const cacheKey = this.generateCacheKey(`tv_${showId}_logo`, { preferredLanguage }); - + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1201,15 +1133,15 @@ export class TMDBService { }); const images = response.data; - + let result: string | null = null; - + if (images && images.logos && images.logos.length > 0) { // First prioritize preferred language SVG logos if not English if (preferredLanguage !== 'en') { - const preferredSvgLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.svg') && + const preferredSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && logo.iso_639_1 === preferredLanguage ); if (preferredSvgLogo) { @@ -1218,19 +1150,19 @@ export class TMDBService { // Then preferred language PNG logos if (!result) { - const preferredPngLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.png') && + const preferredPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && logo.iso_639_1 === preferredLanguage ); if (preferredPngLogo) { result = this.getImageUrl(preferredPngLogo.file_path); } } - + // Then any preferred language logo if (!result) { - const preferredLogo = images.logos.find((logo: any) => + const preferredLogo = images.logos.find((logo: any) => logo.iso_639_1 === preferredLanguage ); if (preferredLogo) { @@ -1241,9 +1173,9 @@ export class TMDBService { // First prioritize English SVG logos if (!result) { - const enSvgLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.svg') && + const enSvgLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.svg') && logo.iso_639_1 === 'en' ); if (enSvgLogo) { @@ -1253,19 +1185,19 @@ export class TMDBService { // Then English PNG logos if (!result) { - const enPngLogo = images.logos.find((logo: any) => - logo.file_path && - logo.file_path.endsWith('.png') && + const enPngLogo = images.logos.find((logo: any) => + logo.file_path && + logo.file_path.endsWith('.png') && logo.iso_639_1 === 'en' ); if (enPngLogo) { result = this.getImageUrl(enPngLogo.file_path); } } - + // Then any English logo if (!result) { - const enLogo = images.logos.find((logo: any) => + const enLogo = images.logos.find((logo: any) => logo.iso_639_1 === 'en' ); if (enLogo) { @@ -1275,7 +1207,7 @@ export class TMDBService { // Fallback to any SVG logo if (!result) { - const svgLogo = images.logos.find((logo: any) => + const svgLogo = images.logos.find((logo: any) => logo.file_path && logo.file_path.endsWith('.svg') ); if (svgLogo) { @@ -1285,14 +1217,14 @@ export class TMDBService { // Then any PNG logo if (!result) { - const pngLogo = images.logos.find((logo: any) => + const pngLogo = images.logos.find((logo: any) => logo.file_path && logo.file_path.endsWith('.png') ); if (pngLogo) { result = this.getImageUrl(pngLogo.file_path); } } - + // Last resort: any logo if (!result) { result = this.getImageUrl(images.logos[0].file_path); @@ -1311,14 +1243,14 @@ export class TMDBService { */ async getContentLogo(type: 'movie' | 'tv', id: number | string, preferredLanguage: string = 'en'): Promise { try { - const result = type === 'movie' + const result = type === 'movie' ? await this.getMovieImages(id, preferredLanguage) : await this.getTvShowImages(id, preferredLanguage); - + if (result) { } else { } - + return result; } catch (error) { return null; @@ -1330,14 +1262,14 @@ export class TMDBService { */ async getCertification(type: string, id: number): Promise { const cacheKey = this.generateCacheKey(`${type}_${id}_certification`); - + // Check cache const cached = this.getCachedData(cacheKey); if (cached !== null) return cached; try { let result: string | null = null; - + if (type === 'movie') { const response = await axios.get(`${BASE_URL}/movie/${id}/release_dates`, { headers: await this.getHeaders(), @@ -1390,7 +1322,7 @@ export class TMDBService { } } } - + this.setCachedData(cacheKey, result); return result; } catch (error) { @@ -1402,10 +1334,11 @@ export class TMDBService { * Get trending movies or TV shows * @param type 'movie' or 'tv' * @param timeWindow 'day' or 'week' + * @param language Language for localized results */ - async getTrending(type: 'movie' | 'tv', timeWindow: 'day' | 'week'): Promise { - const cacheKey = this.generateCacheKey(`trending_${type}_${timeWindow}`); - + async getTrending(type: 'movie' | 'tv', timeWindow: 'day' | 'week', language: string = 'en-US'): Promise { + const cacheKey = this.generateCacheKey(`trending_${type}_${timeWindow}`, { language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1414,7 +1347,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/trending/${type}/${timeWindow}`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, }), }); @@ -1451,10 +1384,11 @@ export class TMDBService { * Get popular movies or TV shows * @param type 'movie' or 'tv' * @param page Page number for pagination + * @param language Language for localized results */ - async getPopular(type: 'movie' | 'tv', page: number = 1): Promise { - const cacheKey = this.generateCacheKey(`popular_${type}`, { page }); - + async getPopular(type: 'movie' | 'tv', page: number = 1, language: string = 'en-US'): Promise { + const cacheKey = this.generateCacheKey(`popular_${type}`, { page, language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1463,7 +1397,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/${type}/popular`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, page, }), }); @@ -1501,10 +1435,11 @@ export class TMDBService { * Get upcoming/now playing content * @param type 'movie' or 'tv' * @param page Page number for pagination + * @param language Language for localized results */ - async getUpcoming(type: 'movie' | 'tv', page: number = 1): Promise { - const cacheKey = this.generateCacheKey(`upcoming_${type}`, { page }); - + async getUpcoming(type: 'movie' | 'tv', page: number = 1, language: string = 'en-US'): Promise { + const cacheKey = this.generateCacheKey(`upcoming_${type}`, { page, language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1512,11 +1447,11 @@ export class TMDBService { try { // For movies use upcoming, for TV use on_the_air const endpoint = type === 'movie' ? 'upcoming' : 'on_the_air'; - + const response = await axios.get(`${BASE_URL}/${type}/${endpoint}`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, page, }), }); @@ -1554,10 +1489,11 @@ export class TMDBService { * Get now playing movies (currently in theaters) * @param page Page number for pagination * @param region ISO 3166-1 country code (e.g., 'US', 'GB') + * @param language Language for localized results */ - async getNowPlaying(page: number = 1, region: string = 'US'): Promise { - const cacheKey = this.generateCacheKey('now_playing', { page, region }); - + async getNowPlaying(page: number = 1, region: string = 'US', language: string = 'en-US'): Promise { + const cacheKey = this.generateCacheKey('now_playing', { page, region, language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; @@ -1566,7 +1502,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/movie/now_playing`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, page, region, // Filter by region to get accurate theater availability }), @@ -1603,10 +1539,11 @@ export class TMDBService { /** * Get the list of official movie genres from TMDB + * @param language Language for localized genre names */ - async getMovieGenres(): Promise<{ id: number; name: string }[]> { - const cacheKey = this.generateCacheKey('genres_movie'); - + async getMovieGenres(language: string = 'en-US'): Promise<{ id: number; name: string }[]> { + const cacheKey = this.generateCacheKey('genres_movie', { language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote<{ id: number; name: string }[]>(cacheKey); if (cached !== null) return cached; @@ -1615,7 +1552,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/genre/movie/list`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, }), }); const data = response.data.genres || []; @@ -1628,10 +1565,11 @@ export class TMDBService { /** * Get the list of official TV genres from TMDB + * @param language Language for localized genre names */ - async getTvGenres(): Promise<{ id: number; name: string }[]> { - const cacheKey = this.generateCacheKey('genres_tv'); - + async getTvGenres(language: string = 'en-US'): Promise<{ id: number; name: string }[]> { + const cacheKey = this.generateCacheKey('genres_tv', { language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote<{ id: number; name: string }[]>(cacheKey); if (cached !== null) return cached; @@ -1640,7 +1578,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/genre/tv/list`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, }), }); const data = response.data.genres || []; @@ -1656,36 +1594,36 @@ export class TMDBService { * @param type 'movie' or 'tv' * @param genreName The genre name to filter by * @param page Page number for pagination + * @param language Language for localized results */ - async discoverByGenre(type: 'movie' | 'tv', genreName: string, page: number = 1): Promise { - const cacheKey = this.generateCacheKey(`discover_${type}`, { genreName, page }); - + async discoverByGenre(type: 'movie' | 'tv', genreName: string, page: number = 1, language: string = 'en-US'): Promise { + const cacheKey = this.generateCacheKey(`discover_${type}`, { genreName, page, language }); + // Check cache (local or remote) const cached = await this.getFromCacheOrRemote(cacheKey); if (cached !== null) return cached; try { // First get the genre ID from the name - const genreList = type === 'movie' - ? await this.getMovieGenres() - : await this.getTvGenres(); - + const genreList = type === 'movie' + ? await this.getMovieGenres(language) + : await this.getTvGenres(language); + const genre = genreList.find(g => g.name.toLowerCase() === genreName.toLowerCase()); - + if (!genre) { return []; } - + const response = await axios.get(`${BASE_URL}/discover/${type}`, { headers: await this.getHeaders(), params: await this.getParams({ - language: 'en-US', + language, sort_by: 'popularity.desc', include_adult: false, include_video: false, page, with_genres: genre.id.toString(), - with_original_language: 'en', }), }); diff --git a/src/services/traktService.ts b/src/services/traktService.ts index e267dba..8a51f91 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -577,6 +577,10 @@ export type TraktContentCommentLegacy = | TraktEpisodeComment | TraktListComment; + +const TRAKT_MAINTENANCE_MODE = true; +const TRAKT_MAINTENANCE_MESSAGE = 'Trakt integration is temporarily unavailable for maintenance. Please try again later.'; + export class TraktService { private static instance: TraktService; private accessToken: string | null = null; @@ -584,6 +588,16 @@ export class TraktService { private tokenExpiry: number = 0; private isInitialized: boolean = false; + + public isMaintenanceMode(): boolean { + return TRAKT_MAINTENANCE_MODE; + } + + + public getMaintenanceMessage(): string { + return TRAKT_MAINTENANCE_MESSAGE; + } + // Rate limiting - Optimized for real-time scrobbling private lastApiCall: number = 0; private readonly MIN_API_INTERVAL = 500; // Reduced to 500ms for faster updates @@ -726,6 +740,12 @@ export class TraktService { * Check if the user is authenticated with Trakt */ public async isAuthenticated(): Promise { + // During maintenance, report as not authenticated to disable all syncing + if (this.isMaintenanceMode()) { + logger.log('[TraktService] Maintenance mode: reporting as not authenticated'); + return false; + } + await this.ensureInitialized(); if (!this.accessToken) { @@ -756,6 +776,12 @@ export class TraktService { * Exchange the authorization code for an access token */ public async exchangeCodeForToken(code: string, codeVerifier: string): Promise { + // Block authentication during maintenance + if (this.isMaintenanceMode()) { + logger.warn('[TraktService] Maintenance mode: blocking new authentication'); + return false; + } + await this.ensureInitialized(); try { @@ -887,6 +913,12 @@ export class TraktService { body?: any, retryCount: number = 0 ): Promise { + // Block all API requests during maintenance + if (this.isMaintenanceMode()) { + logger.warn('[TraktService] Maintenance mode: blocking API request to', endpoint); + throw new Error(TRAKT_MAINTENANCE_MESSAGE); + } + await this.ensureInitialized(); // Rate limiting: ensure minimum interval between API calls @@ -1106,10 +1138,10 @@ export class TraktService { ? imdbId : `tt${imdbId}`; - const response = await this.client.get('/sync/watched/movies'); - const movies = Array.isArray(response.data) ? response.data : []; + const movies = await this.apiRequest('/sync/watched/movies'); + const moviesArray = Array.isArray(movies) ? movies : []; - return movies.some( + return moviesArray.some( (m: any) => m.movie?.ids?.imdb === imdb ); } catch (err) { From 3e63efc178673ddec62739f05e85ef668c7b932e Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 Jan 2026 15:57:23 +0530 Subject: [PATCH 05/14] added parallel season fetching --- src/components/metadata/SeriesContent.tsx | 11 +++++- src/hooks/useMetadata.ts | 44 ++++++++++++++--------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index debdee0..caa8d2e 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -489,9 +489,18 @@ const SeriesContentComponent: React.FC = ({ }; }, []); - // Add effect to scroll to selected season + // Track previous season to only scroll when it actually changes + const previousSeasonRef = React.useRef(null); + + // Add effect to scroll to selected season (only when season changes, not on every groupedEpisodes update) useEffect(() => { if (selectedSeason && seasonScrollViewRef.current && Object.keys(groupedEpisodes).length > 0) { + // Only scroll if the season actually changed (not just groupedEpisodes update) + if (previousSeasonRef.current === selectedSeason) { + return; // Season didn't change, don't scroll + } + previousSeasonRef.current = selectedSeason; + // 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); diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index ce27c61..e9fee09 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -1157,27 +1157,37 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Use just the language code (e.g., 'ar', not 'ar-US') for TMDB API const lang = settings.tmdbLanguagePreference || 'en'; const seasons = Object.keys(groupedAddonEpisodes).map(Number); - for (const seasonNum of seasons) { - const seasonEps = groupedAddonEpisodes[seasonNum]; - // Parallel fetch a reasonable batch (limit concurrency implicitly by season) - const localized = await Promise.all( - seasonEps.map(async ep => { - try { - const data = await tmdbService.getEpisodeDetails(Number(tmdbIdToUse), seasonNum, ep.episode_number, lang); - if (data) { + + // Fetch all seasons in parallel (much faster than fetching each episode individually) + const seasonPromises = seasons.map(async seasonNum => { + try { + // getSeasonDetails returns all episodes for a season in one call + const seasonData = await tmdbService.getSeasonDetails(Number(tmdbIdToUse), seasonNum, undefined, lang); + if (seasonData && seasonData.episodes) { + // Create a map of episode number -> localized data for fast lookup + const localizedMap = new Map(); + for (const ep of seasonData.episodes) { + localizedMap.set(ep.episode_number, { name: ep.name, overview: ep.overview }); + } + + // Merge localized data into addon episodes + groupedAddonEpisodes[seasonNum] = groupedAddonEpisodes[seasonNum].map(ep => { + const localized = localizedMap.get(ep.episode_number); + if (localized) { return { ...ep, - name: data.name || ep.name, - overview: data.overview || ep.overview, + name: localized.name || ep.name, + overview: localized.overview || ep.overview, }; } - } catch { } - return ep; - }) - ); - groupedAddonEpisodes[seasonNum] = localized; - } - if (__DEV__) logger.log('[useMetadata] merged localized episode names/overviews from TMDB'); + return ep; + }); + } + } catch { } + }); + + await Promise.all(seasonPromises); + if (__DEV__) logger.log('[useMetadata] merged localized episode names/overviews from TMDB (batch)'); } } catch (e) { if (__DEV__) console.log('[useMetadata] failed to merge localized episode text', e); From 59f77ac83117c33f968bda078a78c6f41f8c39ec Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 4 Jan 2026 16:17:16 +0530 Subject: [PATCH 06/14] optimisations for exo --- .../com/brentvatne/exoplayer/ReactExoplayerView.java | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index cb6f869..a939148 100644 --- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -270,6 +270,7 @@ public class ReactExoplayerView extends FrameLayout implements private final String instanceId = String.valueOf(UUID.randomUUID()); private CmcdConfiguration.Factory cmcdConfigurationFactory; + private static final ExecutorService SHARED_EXECUTOR = Executors.newSingleThreadExecutor(); public void setCmcdConfigurationFactory(CmcdConfiguration.Factory factory) { this.cmcdConfigurationFactory = factory; @@ -683,8 +684,7 @@ public class ReactExoplayerView extends FrameLayout implements exoPlayerView.invalidateAspectRatio(); // DRM session manager creation must be done on a different thread to prevent // crashes so we start a new thread - ExecutorService es = Executors.newSingleThreadExecutor(); - es.execute(() -> { + SHARED_EXECUTOR.execute(() -> { // DRM initialization must run on a different thread if (viewHasDropped && runningSource == source) { return; @@ -1518,8 +1518,7 @@ public class ReactExoplayerView extends FrameLayout implements ArrayList textTracks = getTextTrackInfo(); if (source.getContentStartTime() != -1) { - ExecutorService es = Executors.newSingleThreadExecutor(); - es.execute(() -> { + SHARED_EXECUTOR.execute(() -> { // To prevent ANRs caused by getVideoTrackInfo we run this on a different thread // and notify the player only when we're done ArrayList videoTracks = getVideoTrackInfoFromManifest(); @@ -1632,12 +1631,11 @@ public class ReactExoplayerView extends FrameLayout implements // conditions @WorkerThread private ArrayList getVideoTrackInfoFromManifest(int retryCount) { - ExecutorService es = Executors.newSingleThreadExecutor(); final DataSource dataSource = this.mediaDataSourceFactory.createDataSource(); final Uri sourceUri = source.getUri(); final long startTime = source.getContentStartTime() * 1000 - 100; // s -> ms with 100ms offset - Future> result = es.submit(new Callable() { + Future> result = SHARED_EXECUTOR.submit(new Callable>() { final DataSource ds = dataSource; final Uri uri = sourceUri; final long startTimeUs = startTime * 1000; // ms -> us @@ -1687,7 +1685,6 @@ public class ReactExoplayerView extends FrameLayout implements if (results == null && retryCount < 1) { return this.getVideoTrackInfoFromManifest(++retryCount); } - es.shutdown(); return results; } catch (Exception e) { DebugLog.w(TAG, "error in getVideoTrackInfoFromManifest handling request:" + e.getMessage()); From 2835ede7478d06e5437667c57b4a7fc24531fdce Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 Jan 2026 18:43:44 +0530 Subject: [PATCH 07/14] Changed Trakt Continue watch Sync Behaviour. now fetches directly from api when authenticated and doesn't merges to local storage. --- .../home/ContinueWatchingSection.tsx | 633 +++++++----------- src/services/traktService.ts | 2 +- 2 files changed, 235 insertions(+), 400 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index e5a7d09..01fb778 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -10,7 +10,7 @@ import { ActivityIndicator, Platform } from 'react-native'; -import { FlashList } from '@shopify/flash-list'; +import { FlatList } from 'react-native'; import Animated, { FadeIn, Layout } from 'react-native-reanimated'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -363,300 +363,237 @@ const ContinueWatchingSection = React.forwardRef((props, re }; try { - const allProgress = await storageService.getAllWatchProgress(); - if (Object.keys(allProgress).length === 0) { - setContinueWatchingItems([]); - return; - } + // If Trakt is authenticated, skip local storage and only use Trakt playback + const traktService = TraktService.getInstance(); + const isTraktAuthed = await traktService.isAuthenticated(); - // Group progress items by content ID - const contentGroups: Record }> = {}; - for (const key in allProgress) { - const keyParts = key.split(':'); - const [type, id, ...episodeIdParts] = keyParts; - const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined; - const progress = allProgress[key]; - const progressPercent = - progress.duration > 0 - ? (progress.currentTime / progress.duration) * 100 - : 0; - // Skip fully watched movies - if (type === 'movie' && progressPercent >= 85) continue; - // Skip movies with no actual progress (ensure > 0%) - if (type === 'movie' && (!isFinite(progressPercent) || progressPercent <= 0)) continue; - const contentKey = `${type}:${id}`; - if (!contentGroups[contentKey]) contentGroups[contentKey] = { type, id, episodes: [] }; - contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent }); - } + // Declare groupPromises outside the if/else block + let groupPromises: Promise[] = []; - // Fetch Trakt watched movies once and reuse - const traktMoviesSetPromise = (async () => { - try { - const traktService = TraktService.getInstance(); - const isAuthed = await traktService.isAuthenticated(); - if (!isAuthed) return new Set(); - if (typeof (traktService as any).getWatchedMovies === 'function') { - const watched = await (traktService as any).getWatchedMovies(); - const watchedSet = new Set(); - - if (Array.isArray(watched)) { - watched.forEach((w: any) => { - const ids = w?.movie?.ids; - if (!ids) return; - - if (ids.imdb) { - const imdb = ids.imdb; - watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`); - } - if (ids.tmdb) { - watchedSet.add(ids.tmdb.toString()); - } - }); - } - return watchedSet; - } - return new Set(); - } catch { - return new Set(); + if (isTraktAuthed) { + // Just skip local storage - Trakt will populate with correct timestamps + // Don't clear existing items to avoid flicker + } else { + // Non-Trakt: use local storage + const allProgress = await storageService.getAllWatchProgress(); + if (Object.keys(allProgress).length === 0) { + setContinueWatchingItems([]); + return; } - })(); - // Fetch Trakt watched shows once and reuse - const traktShowsSetPromise = (async () => { - try { - const traktService = TraktService.getInstance(); - const isAuthed = await traktService.isAuthenticated(); - if (!isAuthed) return new Set(); - - if (typeof (traktService as any).getWatchedShows === 'function') { - const watched = await (traktService as any).getWatchedShows(); - const watchedSet = new Set(); - - if (Array.isArray(watched)) { - watched.forEach((show: any) => { - const ids = show?.show?.ids; - if (!ids) return; - - const imdbId = ids.imdb; - const tmdbId = ids.tmdb; - - if (show.seasons && Array.isArray(show.seasons)) { - show.seasons.forEach((season: any) => { - if (season.episodes && Array.isArray(season.episodes)) { - season.episodes.forEach((episode: any) => { - if (imdbId) { - const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; - watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`); - } - if (tmdbId) { - watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`); - } - }); - } - }); - } - }); - } - return watchedSet; - } - return new Set(); - } catch { - return new Set(); + // Group progress items by content ID + const contentGroups: Record }> = {}; + for (const key in allProgress) { + const keyParts = key.split(':'); + const [type, id, ...episodeIdParts] = keyParts; + const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined; + const progress = allProgress[key]; + const progressPercent = + progress.duration > 0 + ? (progress.currentTime / progress.duration) * 100 + : 0; + // Skip fully watched movies + if (type === 'movie' && progressPercent >= 85) continue; + // Skip movies with no actual progress (ensure > 0%) + if (type === 'movie' && (!isFinite(progressPercent) || progressPercent <= 0)) continue; + const contentKey = `${type}:${id}`; + if (!contentGroups[contentKey]) contentGroups[contentKey] = { type, id, episodes: [] }; + contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent }); } - })(); - // Process each content group concurrently, merging results as they arrive - const groupPromises = Object.values(contentGroups).map(async (group) => { - try { - if (!isSupportedId(group.id)) return; - // Skip movies that are already watched on Trakt - if (group.type === 'movie') { - const watchedSet = await traktMoviesSetPromise; - const imdbId = group.id.startsWith('tt') - ? group.id - : `tt${group.id}`; - if (watchedSet.has(imdbId)) { - // Optional: sync local store to watched to prevent reappearance - try { - await storageService.setWatchProgress(group.id, 'movie', { - currentTime: 1, - duration: 1, - lastUpdated: Date.now(), - traktSynced: true, - traktProgress: 100, - } as any); - } catch (_e) { } - return; + // Fetch Trakt watched movies once and reuse + const traktMoviesSetPromise = (async () => { + try { + const traktService = TraktService.getInstance(); + const isAuthed = await traktService.isAuthenticated(); + if (!isAuthed) return new Set(); + if (typeof (traktService as any).getWatchedMovies === 'function') { + const watched = await (traktService as any).getWatchedMovies(); + const watchedSet = new Set(); + + if (Array.isArray(watched)) { + watched.forEach((w: any) => { + const ids = w?.movie?.ids; + if (!ids) return; + + if (ids.imdb) { + const imdb = ids.imdb; + watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`); + } + if (ids.tmdb) { + watchedSet.add(ids.tmdb.toString()); + } + }); + } + return watchedSet; } + return new Set(); + } catch { + return new Set(); } - const cachedData = await getCachedMetadata(group.type, group.id, group.episodes[0]?.progress?.addonId); - if (!cachedData?.basicContent) return; - const { metadata, basicContent } = cachedData; + })(); - const batch: ContinueWatchingItem[] = []; - for (const episode of group.episodes) { - const { episodeId, progress, progressPercent } = episode; + // Fetch Trakt watched shows once and reuse + const traktShowsSetPromise = (async () => { + try { + const traktService = TraktService.getInstance(); + const isAuthed = await traktService.isAuthenticated(); + if (!isAuthed) return new Set(); - if (group.type === 'series' && progressPercent >= 85) { - // Local progress completion check - if (episodeId) { - let currentSeason: number | undefined; - let currentEpisode: number | undefined; + if (typeof (traktService as any).getWatchedShows === 'function') { + const watched = await (traktService as any).getWatchedShows(); + const watchedSet = new Set(); - const match = episodeId.match(/s(\d+)e(\d+)/i); + if (Array.isArray(watched)) { + watched.forEach((show: any) => { + const ids = show?.show?.ids; + if (!ids) return; + + const imdbId = ids.imdb; + const tmdbId = ids.tmdb; + + if (show.seasons && Array.isArray(show.seasons)) { + show.seasons.forEach((season: any) => { + if (season.episodes && Array.isArray(season.episodes)) { + season.episodes.forEach((episode: any) => { + if (imdbId) { + const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`); + } + if (tmdbId) { + watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`); + } + }); + } + }); + } + }); + } + return watchedSet; + } + return new Set(); + } catch { + return new Set(); + } + })(); + + // Process each content group concurrently, merging results as they arrive + groupPromises = Object.values(contentGroups).map(async (group) => { + try { + if (!isSupportedId(group.id)) return; + // Skip movies that are already watched on Trakt + if (group.type === 'movie') { + const watchedSet = await traktMoviesSetPromise; + const imdbId = group.id.startsWith('tt') + ? group.id + : `tt${group.id}`; + if (watchedSet.has(imdbId)) { + // Optional: sync local store to watched to prevent reappearance + try { + await storageService.setWatchProgress(group.id, 'movie', { + currentTime: 1, + duration: 1, + lastUpdated: Date.now(), + traktSynced: true, + traktProgress: 100, + } as any); + } catch (_e) { } + return; + } + } + const cachedData = await getCachedMetadata(group.type, group.id, group.episodes[0]?.progress?.addonId); + if (!cachedData?.basicContent) return; + const { metadata, basicContent } = cachedData; + + const batch: ContinueWatchingItem[] = []; + for (const episode of group.episodes) { + const { episodeId, progress, progressPercent } = episode; + + if (group.type === 'series' && progressPercent >= 85) { + // Skip completed episodes - don't add "next episode" here + // The Trakt playback endpoint handles in-progress items + continue; + } + + let season: number | undefined; + let episodeNumber: number | undefined; + let episodeTitle: string | undefined; + let isWatchedOnTrakt = false; + + if (episodeId && group.type === 'series') { + let match = episodeId.match(/s(\d+)e(\d+)/i); if (match) { - currentSeason = parseInt(match[1], 10); - currentEpisode = parseInt(match[2], 10); + season = parseInt(match[1], 10); + episodeNumber = parseInt(match[2], 10); + episodeTitle = `Episode ${episodeNumber}`; } 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 (parts.length >= 3) { + const seasonPart = parts[parts.length - 2]; + const episodePart = parts[parts.length - 1]; + const seasonNum = parseInt(seasonPart, 10); + const episodeNum = parseInt(episodePart, 10); if (!isNaN(seasonNum) && !isNaN(episodeNum)) { - currentSeason = seasonNum; - currentEpisode = episodeNum; + season = seasonNum; + episodeNumber = episodeNum; + episodeTitle = `Episode ${episodeNumber}`; } } } - if (currentSeason !== undefined && currentEpisode !== undefined) { - const traktService = TraktService.getInstance(); - let nextEpisode: any = null; + // Check if this specific episode is watched on Trakt + if (season !== undefined && episodeNumber !== undefined) { + const watchedEpisodesSet = await traktShowsSetPromise; + // Try with both raw ID and tt-prefixed ID, and TMDB ID (which is just the ID string) + const rawId = group.id.replace(/^tt/, ''); + const ttId = `tt${rawId}`; - try { - const isAuthed = await traktService.isAuthenticated(); - if (isAuthed && typeof (traktService as any).getShowWatchedProgress === 'function') { - const showProgress = await (traktService as any).getShowWatchedProgress(group.id); + if (watchedEpisodesSet.has(`${ttId}:${season}:${episodeNumber}`) || + watchedEpisodesSet.has(`${rawId}:${season}:${episodeNumber}`) || + watchedEpisodesSet.has(`${group.id}:${season}:${episodeNumber}`)) { + isWatchedOnTrakt = true; - if (showProgress && !showProgress.completed && showProgress.next_episode) { - nextEpisode = showProgress.next_episode; - } - } - } catch { - - } - - if (!nextEpisode && metadata?.videos) { - nextEpisode = findNextEpisode( - currentSeason, - currentEpisode, - metadata.videos - ); - } - - if (nextEpisode) { - batch.push({ - ...basicContent, - id: group.id, - type: group.type, - progress: 0, - lastUpdated: progress.lastUpdated, - season: nextEpisode.season, - episode: nextEpisode.number ?? nextEpisode.episode, - episodeTitle: nextEpisode.title || `Episode ${nextEpisode.number ?? nextEpisode.episode}`, - addonId: progress.addonId, - } as ContinueWatchingItem); + // Update local storage to reflect watched status + try { + await storageService.setWatchProgress( + group.id, + 'series', + { + currentTime: 1, + duration: 1, + lastUpdated: Date.now(), + traktSynced: true, + traktProgress: 100, + } as any, + episodeId + ); + } catch (_e) { } } } - } - continue; + + // If watched on Trakt, skip it - Trakt playback handles in-progress items + if (isWatchedOnTrakt) { + continue; + } + + batch.push({ + ...basicContent, + progress: progressPercent, + lastUpdated: progress.lastUpdated, + season, + episode: episodeNumber, + episodeTitle, + addonId: progress.addonId, + } as ContinueWatchingItem); } - let season: number | undefined; - let episodeNumber: number | undefined; - let episodeTitle: string | undefined; - let isWatchedOnTrakt = false; - - if (episodeId && group.type === 'series') { - let match = episodeId.match(/s(\d+)e(\d+)/i); - if (match) { - season = parseInt(match[1], 10); - episodeNumber = parseInt(match[2], 10); - episodeTitle = `Episode ${episodeNumber}`; - } else { - const parts = episodeId.split(':'); - if (parts.length >= 3) { - const seasonPart = parts[parts.length - 2]; - const episodePart = parts[parts.length - 1]; - const seasonNum = parseInt(seasonPart, 10); - const episodeNum = parseInt(episodePart, 10); - if (!isNaN(seasonNum) && !isNaN(episodeNum)) { - season = seasonNum; - episodeNumber = episodeNum; - episodeTitle = `Episode ${episodeNumber}`; - } - } - } - - // Check if this specific episode is watched on Trakt - if (season !== undefined && episodeNumber !== undefined) { - const watchedEpisodesSet = await traktShowsSetPromise; - // Try with both raw ID and tt-prefixed ID, and TMDB ID (which is just the ID string) - const rawId = group.id.replace(/^tt/, ''); - const ttId = `tt${rawId}`; - - if (watchedEpisodesSet.has(`${ttId}:${season}:${episodeNumber}`) || - watchedEpisodesSet.has(`${rawId}:${season}:${episodeNumber}`) || - watchedEpisodesSet.has(`${group.id}:${season}:${episodeNumber}`)) { - isWatchedOnTrakt = true; - - // Update local storage to reflect watched status - try { - await storageService.setWatchProgress( - group.id, - 'series', - { - currentTime: 1, - duration: 1, - lastUpdated: Date.now(), - traktSynced: true, - traktProgress: 100, - } as any, - episodeId - ); - } catch (_e) { } - } - } - } - - // If watched on Trakt, treat it as completed (try to find next episode) - if (isWatchedOnTrakt) { - 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: nextEpisodeVideo.season, - episode: nextEpisodeVideo.episode, - episodeTitle: `Episode ${nextEpisodeVideo.episode}`, - addonId: progress.addonId, - } as ContinueWatchingItem); - } - } - continue; - } - - batch.push({ - ...basicContent, - progress: progressPercent, - lastUpdated: progress.lastUpdated, - season, - episode: episodeNumber, - episodeTitle, - addonId: progress.addonId, - } as ContinueWatchingItem); + if (batch.length > 0) await mergeBatchIntoState(batch); + } catch (error) { + // Continue processing other groups even if one fails } - - if (batch.length > 0) await mergeBatchIntoState(batch); - } catch (error) { - // Continue processing other groups even if one fails - } - }); + }); + } // End of else block for non-Trakt users // TRAKT: fetch playback progress (in-progress items) and history, merge incrementally const traktMergePromise = (async () => { @@ -674,33 +611,26 @@ const ContinueWatchingSection = React.forwardRef((props, re lastTraktSyncRef.current = now; - // Fetch both playback progress (paused items) and watch history in parallel - const [playbackItems, historyItems, watchedShows] = await Promise.all([ - traktService.getPlaybackProgress(), // Items with actual progress % - traktService.getWatchedEpisodesHistory(1, 200), // Completed episodes - traktService.getWatchedShows(), // For reset_at handling - ]); + // Fetch only playback progress (paused items with actual progress %) + // Removed: history items and watched shows - redundant with local logic + const playbackItems = await traktService.getPlaybackProgress(); + - // Build a map of shows with reset_at for re-watching support - const showResetMap: Record = {}; - for (const show of watchedShows) { - if (show.show?.ids?.imdb && show.reset_at) { - const imdbId = show.show.ids.imdb.startsWith('tt') - ? show.show.ids.imdb - : `tt${show.show.ids.imdb}`; - showResetMap[imdbId] = new Date(show.reset_at).getTime(); - } - } const traktBatch: ContinueWatchingItem[] = []; - const processedShows = new Set(); // Track which shows we've added // STEP 1: Process playback progress items (in-progress, paused) // These have actual progress percentage from Trakt + const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); for (const item of playbackItems) { try { - // Skip items with very low or very high progress - if (item.progress <= 0 || item.progress >= 85) continue; + // Skip items with < 2% progress (accidental clicks) + if (item.progress < 2) continue; + // Skip items with >= 85% progress (completed) + if (item.progress >= 85) continue; + // Skip items older than 30 days + const pausedAt = new Date(item.paused_at).getTime(); + if (pausedAt < thirtyDaysAgo) continue; if (item.type === 'movie' && item.movie?.ids?.imdb) { const imdbId = item.movie.ids.imdb.startsWith('tt') @@ -735,13 +665,7 @@ const ContinueWatchingSection = React.forwardRef((props, re const showKey = `series:${showImdb}`; if (recentlyRemovedRef.current.has(showKey)) continue; - // Check reset_at - skip if this was paused before re-watch started - const resetTime = showResetMap[showImdb]; const pausedAt = new Date(item.paused_at).getTime(); - if (resetTime && pausedAt < resetTime) { - logger.log(`πŸ”„ [TraktPlayback] Skipping ${showImdb} S${item.episode.season}E${item.episode.number} - paused before reset_at`); - continue; - } const cachedData = await getCachedMetadata('series', showImdb); if (!cachedData?.basicContent) continue; @@ -758,7 +682,6 @@ const ContinueWatchingSection = React.forwardRef((props, re addonId: undefined, } as ContinueWatchingItem); - processedShows.add(showImdb); logger.log(`πŸ“Ί [TraktPlayback] Adding ${item.show.title} S${item.episode.season}E${item.episode.number} with ${item.progress.toFixed(1)}% progress`); } } catch (err) { @@ -766,110 +689,22 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - // STEP 2: Process watch history for shows NOT in playback progress - // Find the next episode for completed shows - const latestWatchedByShow: Record = {}; - for (const item of historyItems) { - if (item.type !== 'episode') continue; - const showImdb = item.show?.ids?.imdb - ? (item.show.ids.imdb.startsWith('tt') ? item.show.ids.imdb : `tt${item.show.ids.imdb}`) - : null; - if (!showImdb) continue; - - // Skip if we already have an in-progress episode for this show - if (processedShows.has(showImdb)) continue; - - const season = item.episode?.season; - const epNum = item.episode?.number; - if (season === undefined || epNum === undefined) continue; - - const watchedAt = new Date(item.watched_at).getTime(); - - // Check reset_at - skip episodes watched before re-watch started - const resetTime = showResetMap[showImdb]; - if (resetTime && watchedAt < resetTime) { - continue; // This was watched in a previous viewing - } - - const existing = latestWatchedByShow[showImdb]; - if (!existing || existing.watchedAt < watchedAt) { - latestWatchedByShow[showImdb] = { season, episode: epNum, watchedAt }; - } - } - - // Add next episodes for completed shows - for (const [showId, info] of Object.entries(latestWatchedByShow)) { - try { - // Check if this show was recently removed - const showKey = `series:${showId}`; - if (recentlyRemovedRef.current.has(showKey)) { - logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`); - continue; - } - - const cachedData = await getCachedMetadata('series', showId); - if (!cachedData?.basicContent) continue; - const { metadata, basicContent } = cachedData; - - const traktService = TraktService.getInstance(); - let showProgress: any = null; - - try { - showProgress = await (traktService as any).getShowWatchedProgress?.(showId); - } catch { - showProgress = null; - } - - if (!showProgress || showProgress.completed || !showProgress.next_episode) { - logger.log(`🚫 [TraktSync] Skipping completed show: ${showId}`); - continue; - } - - const nextEp = showProgress.next_episode; - - logger.log(`βž• [TraktSync] Adding next episode for ${showId}: S${nextEp.season}E${nextEp.number}`); - - traktBatch.push({ - ...basicContent, - id: showId, - type: 'series', - progress: 0, - lastUpdated: info.watchedAt, - season: nextEp.season, - episode: nextEp.number, - episodeTitle: nextEp.title || `Episode ${nextEp.number}`, - addonId: undefined, - } as ContinueWatchingItem); - - // Persist "watched" progress for the episode that Trakt reported - if (!recentlyRemovedRef.current.has(showKey)) { - const watchedEpisodeId = `${showId}:${info.season}:${info.episode}`; - const existingProgress = allProgress[`series:${showId}:${watchedEpisodeId}`]; - const existingPercent = existingProgress ? (existingProgress.currentTime / existingProgress.duration) * 100 : 0; - if (!existingProgress || existingPercent < 85) { - await storageService.setWatchProgress( - showId, - 'series', - { - currentTime: 1, - duration: 1, - lastUpdated: info.watchedAt, - traktSynced: true, - traktProgress: 100, - } as any, - `${info.season}:${info.episode}` - ); - } - } - } catch (err) { - // Continue with other shows - } - } - - // Merge all Trakt items as a single batch to ensure proper sorting + // Set Trakt playback items as state (replace, don't merge with local storage) if (traktBatch.length > 0) { - logger.log(`πŸ“‹ [TraktSync] Merging ${traktBatch.length} items from Trakt (playback + history)`); - await mergeBatchIntoState(traktBatch); + // Dedupe: for series, keep only the latest episode per show + const deduped = new Map(); + for (const item of traktBatch) { + const key = `${item.type}:${item.id}`; + const existing = deduped.get(key); + if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + deduped.set(key, item); + } + } + const uniqueItems = Array.from(deduped.values()); + logger.log(`πŸ“‹ [TraktSync] Setting ${uniqueItems.length} items from Trakt playback (deduped from ${traktBatch.length})`); + // Sort by lastUpdated descending and set directly + const sortedBatch = uniqueItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + setContinueWatchingItems(sortedBatch); } } catch (err) { logger.error('[TraktSync] Error in Trakt merge:', err); @@ -1353,8 +1188,8 @@ const ContinueWatchingSection = React.forwardRef((props, re - (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0))} renderItem={renderContinueWatchingItem} keyExtractor={keyExtractor} horizontal diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 8a51f91..ae97e2e 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -578,7 +578,7 @@ export type TraktContentCommentLegacy = | TraktListComment; -const TRAKT_MAINTENANCE_MODE = true; +const TRAKT_MAINTENANCE_MODE = false; const TRAKT_MAINTENANCE_MESSAGE = 'Trakt integration is temporarily unavailable for maintenance. Please try again later.'; export class TraktService { From 81b97da75eeb368892928bf9abad44d7424ca402 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 4 Jan 2026 20:50:37 +0530 Subject: [PATCH 08/14] chore: trakt update --- .../home/ContinueWatchingSection.tsx | 24 +++++++++++++++---- src/screens/TraktSettingsScreen.tsx | 2 +- src/services/traktService.ts | 20 ++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 01fb778..f100ad9 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -39,6 +39,7 @@ interface ContinueWatchingItem extends StreamingContent { addonPoster?: string; addonName?: string; addonDescription?: string; + traktPlaybackId?: number; // Trakt playback ID for removal } // Define the ref interface @@ -652,6 +653,7 @@ const ContinueWatchingSection = React.forwardRef((props, re progress: item.progress, lastUpdated: pausedAt, addonId: undefined, + traktPlaybackId: item.id, // Store playback ID for removal } as ContinueWatchingItem); logger.log(`πŸ“Ί [TraktPlayback] Adding movie ${item.movie.title} with ${item.progress.toFixed(1)}% progress`); @@ -680,6 +682,7 @@ const ContinueWatchingSection = React.forwardRef((props, re episode: item.episode.number, episodeTitle: item.episode.title || `Episode ${item.episode.number}`, addonId: undefined, + traktPlaybackId: item.id, // Store playback ID for removal } as ContinueWatchingItem); logger.log(`πŸ“Ί [TraktPlayback] Adding ${item.show.title} S${item.episode.season}E${item.episode.number} with ${item.progress.toFixed(1)}% progress`); @@ -934,7 +937,7 @@ const ContinueWatchingSection = React.forwardRef((props, re }, [navigation, settings.useCachedStreams, settings.openMetadataScreenWhenCacheDisabled]); // Handle long press to delete (moved before renderContinueWatchingItem) - const handleLongPress = useCallback((item: ContinueWatchingItem) => { + const handleLongPress = useCallback(async (item: ContinueWatchingItem) => { try { // Trigger haptic feedback Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); @@ -942,8 +945,17 @@ const ContinueWatchingSection = React.forwardRef((props, re // Ignore haptic errors } + const traktService = TraktService.getInstance(); + const isAuthed = await traktService.isAuthenticated(); + setAlertTitle('Remove from Continue Watching'); - setAlertMessage(`Remove "${item.name}" from your continue watching list?`); + + if (isAuthed) { + setAlertMessage(`Remove "${item.name}" from your continue watching list?\n\nThis will also remove it from your Trakt Continue Watching.`); + } else { + setAlertMessage(`Remove "${item.name}" from your continue watching list?`); + } + setAlertActions([ { label: 'Cancel', @@ -958,11 +970,13 @@ const ContinueWatchingSection = React.forwardRef((props, re try { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); await storageService.removeAllWatchProgressForContent(item.id, item.type, { addBaseTombstone: true }); - const traktService = TraktService.getInstance(); - const isAuthed = await traktService.isAuthenticated(); + if (isAuthed) { let traktResult = false; - if (item.type === 'movie') { + // If we have a playback ID (from sync/playback), use that to remove from Continue Watching + if (item.traktPlaybackId) { + traktResult = await traktService.removePlaybackItem(item.traktPlaybackId); + } else if (item.type === 'movie') { traktResult = await traktService.removeMovieFromHistory(item.id); } else if (item.type === 'series' && item.season !== undefined && item.episode !== undefined) { traktResult = await traktService.removeEpisodeFromHistory(item.id, item.season, item.episode); diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index dff9688..06d7089 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -417,7 +417,7 @@ const TraktSettingsScreen: React.FC = () => { styles.infoText, { color: currentTheme.colors.mediumEmphasis } ]}> - When connected to Trakt, Continue Watching is sourced from Trakt. Account sync for watch progress is disabled to avoid conflicts. + When connected to Trakt, full history is synced directly from the API and is not written to local storage. Your Continue Watching list reflects your global Trakt progress. diff --git a/src/services/traktService.ts b/src/services/traktService.ts index ae97e2e..fee8183 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -2722,6 +2722,26 @@ export class TraktService { } } + /** + * Remove a playback item from Trakt (Continue Watching) by Playback ID + */ + public async removePlaybackItem(playbackId: number): Promise { + try { + logger.log(`πŸ” [TraktService] removePlaybackItem called for playback ID: ${playbackId}`); + if (!playbackId) return false; + + // Use DELETE /sync/playback/{id} + // Note: The ID here is the playback ID, not the movie/episode ID + await this.apiRequest(`/sync/playback/${playbackId}`, 'DELETE'); + + logger.log(`βœ… [TraktService] Successfully removed playback item ${playbackId}. Response: 204 No Content (Standard for DELETE)`); + return true; + } catch (error) { + logger.error(`[TraktService] Failed to remove playback item ${playbackId}:`, error); + return false; + } + } + /** * Remove entire show from watched history by IMDB ID */ From 4dd1fca0a7de14b66c8980515ba7b4ba7f576ec1 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 5 Jan 2026 00:02:03 +0530 Subject: [PATCH 09/14] increased cache buffer ksplayer --- ios/KSPlayerView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ios/KSPlayerView.swift b/ios/KSPlayerView.swift index 4863323..818aae7 100644 --- a/ios/KSPlayerView.swift +++ b/ios/KSPlayerView.swift @@ -237,10 +237,10 @@ class KSPlayerView: UIView { options.registerRemoteControll = false // PERFORMANCE OPTIMIZATION: Buffer durations for smooth high bitrate playback - // preferredForwardBufferDuration = 3.0s: Slightly increased to reduce rebuffering during playback - options.preferredForwardBufferDuration = 1.0 - // maxBufferDuration = 120.0s: Increased to allow the player to cache more content ahead of time (2 minutes) - options.maxBufferDuration = 120.0 + // preferredForwardBufferDuration = 5.0s: Increased to prevent stalling on network hiccups + options.preferredForwardBufferDuration = 5.0 + // maxBufferDuration = 300.0s: Increased to allow 5 minutes of cache ahead + options.maxBufferDuration = 300.0 // Enable "second open" to relax startup/seek buffering thresholds (already enabled) options.isSecondOpen = true From ab7f008bbb372cc4be13b7f79954005c21b9739a Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 5 Jan 2026 13:39:02 +0530 Subject: [PATCH 10/14] added toggle to control this week sections --- src/hooks/useSettings.ts | 2 + src/screens/HomeScreen.tsx | 6 ++- src/screens/HomeScreenSettings.tsx | 69 ++++++++++++++++++------------ 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 4165cc4..fd58bca 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -37,6 +37,7 @@ export interface AppSettings { useExternalPlayer: boolean; preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'infuse_livecontainer' | 'external'; showHeroSection: boolean; + showThisWeekSection: boolean; // Toggle "This Week" section featuredContentSource: 'tmdb' | 'catalogs'; heroStyle: 'legacy' | 'carousel' | 'appletv'; selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section @@ -123,6 +124,7 @@ export const DEFAULT_SETTINGS: AppSettings = { useExternalPlayer: false, preferredPlayer: 'internal', showHeroSection: true, + showThisWeekSection: true, // Enabled by default featuredContentSource: 'catalogs', heroStyle: 'appletv', selectedHeroCatalogs: [], // Empty array means all catalogs are selected diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 026ae69..364ccc1 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -667,7 +667,9 @@ const HomeScreen = () => { } // Normal flow when addons are present (featured moved to ListHeaderComponent) - data.push({ type: 'thisWeek', key: 'thisWeek' }); + if (settings.showThisWeekSection) { + data.push({ type: 'thisWeek', key: 'thisWeek' }); + } // Only show a limited number of catalogs initially for performance const catalogsToShow = catalogs.slice(0, visibleCatalogCount); @@ -687,7 +689,7 @@ const HomeScreen = () => { } return data; - }, [hasAddons, catalogs, visibleCatalogCount]); + }, [hasAddons, catalogs, visibleCatalogCount, settings.showThisWeekSection]); const handleLoadMoreCatalogs = useCallback(() => { setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length)); diff --git a/src/screens/HomeScreenSettings.tsx b/src/screens/HomeScreenSettings.tsx index ea31486..6979bff 100644 --- a/src/screens/HomeScreenSettings.tsx +++ b/src/screens/HomeScreenSettings.tsx @@ -64,11 +64,11 @@ const SettingItem: React.FC = ({ const isTabletDevice = Platform.OS !== 'web' && (Dimensions.get('window').width >= 768); return ( - { if (Platform.OS === 'ios') { StatusBar.setHidden(false); } - } catch {} - return () => {}; + } catch { } + return () => { }; }, [isDarkMode, colors.darkBackground]) ); @@ -169,7 +169,7 @@ const HomeScreenSettings: React.FC = () => { if (isTabletDevice && settings.heroStyle !== 'carousel') { updateSetting('heroStyle', 'carousel' as any); } - } catch {} + } catch { } }, [isTabletDevice, settings.heroStyle, updateSetting]); const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => ( @@ -184,20 +184,20 @@ const HomeScreenSettings: React.FC = () => { // Radio button component for content source selection const RadioOption = ({ selected, onPress, label }: { selected: boolean, onPress: () => void, label: string }) => ( - {selected && } {label} @@ -254,9 +254,9 @@ const HomeScreenSettings: React.FC = () => { }, [settings.selectedHeroCatalogs]); const ChevronRight = () => ( - ); @@ -269,30 +269,30 @@ const HomeScreenSettings: React.FC = () => { - Settings - + {/* Empty for now, but ready for future actions */} - + Home Screen Settings {/* Saved indicator */} - { Changes Applied - { isDarkMode={isDarkMode} colors={colors} renderControl={() => ( - handleUpdateSetting('showHeroSection', value)} + handleUpdateSetting('showHeroSection', value)} + /> + )} + /> + ( + handleUpdateSetting('showThisWeekSection', value)} /> )} /> @@ -344,7 +357,7 @@ const HomeScreenSettings: React.FC = () => { Hero Layout { isDarkMode={isDarkMode} colors={colors} renderControl={() => ( - handleUpdateSetting('enableHomeHeroBackground', value)} /> @@ -393,7 +406,7 @@ const HomeScreenSettings: React.FC = () => { Posters Show Titles - handleUpdateSetting('showPosterTitles', value)} /> From edeb6ebe3cf7768e49f21bbcb8e2ac4cbc159871 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 5 Jan 2026 17:54:17 +0530 Subject: [PATCH 11/14] feat: added new poster like layout for continue watching card --- .../home/ContinueWatchingSection.tsx | 215 +++++++++++- src/hooks/useSettings.ts | 2 + .../ContinueWatchingSettingsScreen.tsx | 314 +++++++++++++----- 3 files changed, 448 insertions(+), 83 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index f100ad9..237caf2 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -1002,8 +1002,128 @@ const ContinueWatchingSection = React.forwardRef((props, re setAlertVisible(true); }, [currentTheme.colors.error]); - // Memoized render function for continue watching items - const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( + // Compute poster dimensions for poster-style cards + const computedPosterWidth = useMemo(() => { + switch (deviceType) { + case 'tv': + return 180; + case 'largeTablet': + return 160; + case 'tablet': + return 140; + default: + return 120; + } + }, [deviceType]); + + const computedPosterHeight = useMemo(() => { + return computedPosterWidth * 1.5; // 2:3 aspect ratio + }, [computedPosterWidth]); + + // Memoized render function for poster-style continue watching items + const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( + handleContentPress(item)} + onLongPress={() => handleLongPress(item)} + delayLongPress={800} + > + {/* Poster Image */} + + + + {/* Gradient overlay */} + + + {/* Episode Info Overlay */} + {item.type === 'series' && item.season && item.episode && ( + + + S{item.season} E{item.episode} + + + )} + + {/* Up Next Badge */} + {item.type === 'series' && item.progress === 0 && ( + + UP NEXT + + )} + + {/* Progress Bar */} + {item.progress > 0 && ( + + + + + + )} + + {/* Delete Indicator Overlay */} + {deletingItemId === item.id && ( + + + + )} + + + {/* Title below poster */} + + + {item.name} + + {item.progress > 0 && ( + + {Math.round(item.progress)}% + + )} + + + ), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedPosterWidth, computedPosterHeight, isTV, isLargeTablet, settings.posterBorderRadius]); + + // Memoized render function for wide-style continue watching items + const renderWideStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( ((props, re )} - ), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet]); + ), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet, settings.posterBorderRadius]); + + // Choose the appropriate render function based on settings + const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => { + if (settings.continueWatchingCardStyle === 'poster') { + return renderPosterStyleItem({ item }); + } + return renderWideStyleItem({ item }); + }, [settings.continueWatchingCardStyle, renderPosterStyleItem, renderWideStyleItem]); // Memoized key extractor const keyExtractor = useCallback((item: ContinueWatchingItem) => `continue-${item.id}-${item.type}`, []); @@ -1421,6 +1549,87 @@ const styles = StyleSheet.create({ progressBar: { height: '100%', }, + // Poster-style card styles + posterContentItem: { + overflow: 'visible', + }, + posterImageContainer: { + width: '100%', + overflow: 'hidden', + position: 'relative', + borderWidth: 1.5, + borderColor: 'rgba(255,255,255,0.15)', + elevation: 1, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 1, + }, + posterImage: { + width: '100%', + height: '100%', + }, + posterGradient: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: '50%', + }, + posterEpisodeOverlay: { + position: 'absolute', + bottom: 8, + left: 8, + backgroundColor: 'rgba(0,0,0,0.7)', + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 4, + }, + posterEpisodeText: { + color: '#FFFFFF', + fontWeight: '600', + }, + posterUpNextBadge: { + position: 'absolute', + top: 8, + right: 8, + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 4, + }, + posterUpNextText: { + color: '#FFFFFF', + fontWeight: '700', + letterSpacing: 0.5, + }, + posterProgressContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + }, + posterProgressTrack: { + height: 4, + }, + posterProgressBar: { + height: '100%', + }, + posterTitleContainer: { + paddingHorizontal: 4, + paddingVertical: 8, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + posterTitle: { + fontWeight: '600', + flex: 1, + lineHeight: 18, + }, + posterProgressLabel: { + fontWeight: '500', + marginLeft: 6, + }, }); export default React.memo(ContinueWatchingSection, (prevProps, nextProps) => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index fd58bca..18e5d9a 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -100,6 +100,7 @@ export interface AppSettings { useCachedStreams: boolean; // Enable/disable direct player navigation from Continue Watching cache openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour) + continueWatchingCardStyle: 'wide' | 'poster'; // Card style: 'wide' (horizontal) or 'poster' (vertical) enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content // Android MPV player settings @@ -186,6 +187,7 @@ export const DEFAULT_SETTINGS: AppSettings = { useCachedStreams: false, // Enable by default openMetadataScreenWhenCacheDisabled: true, // Default to StreamsScreen when cache disabled streamCacheTTL: 60 * 60 * 1000, // Default: 1 hour in milliseconds + continueWatchingCardStyle: 'wide', // Default to wide (horizontal) card style enableStreamsBackdrop: true, // Enable by default (new behavior) // Android MPV player settings videoPlayerEngine: 'auto', // Default to auto (ExoPlayer primary, MPV fallback) diff --git a/src/screens/ContinueWatchingSettingsScreen.tsx b/src/screens/ContinueWatchingSettingsScreen.tsx index 72bfdca..4242ab5 100644 --- a/src/screens/ContinueWatchingSettingsScreen.tsx +++ b/src/screens/ContinueWatchingSettingsScreen.tsx @@ -53,7 +53,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => { if (Platform.OS === 'ios') { StatusBar.setHidden(false); } - } catch {} + } catch { } }, [colors.darkBackground]); const handleBack = useCallback(() => { @@ -97,22 +97,22 @@ const ContinueWatchingSettingsScreen: React.FC = () => { /> ); - const SettingItem = ({ - title, - description, - value, - onValueChange, - isLast = false - }: { - title: string; - description: string; - value: boolean; + const SettingItem = ({ + title, + description, + value, + onValueChange, + isLast = false + }: { + title: string; + description: string; + value: boolean; onValueChange: (value: boolean) => void; isLast?: boolean; }) => ( @@ -159,10 +159,10 @@ const ContinueWatchingSettingsScreen: React.FC = () => { return ( - + {/* Header */} - @@ -170,13 +170,13 @@ const ContinueWatchingSettingsScreen: React.FC = () => { Settings - + Continue Watching {/* Content */} - { PLAYBACK BEHAVIOR - handleUpdateSetting('useCachedStreams', value)} - isLast={!settings.useCachedStreams} - /> - {!settings.useCachedStreams && ( handleUpdateSetting('openMetadataScreenWhenCacheDisabled', value)} - isLast={true} + title="Use Cached Streams" + description="When enabled, clicking Continue Watching items will open the player directly using previously played streams. When disabled, opens a content screen instead." + value={settings.useCachedStreams} + onValueChange={(value) => handleUpdateSetting('useCachedStreams', value)} + isLast={!settings.useCachedStreams} /> - )} + {!settings.useCachedStreams && ( + handleUpdateSetting('openMetadataScreenWhenCacheDisabled', value)} + isLast={true} + /> + )} + + + + {/* Card Appearance Section */} + + CARD APPEARANCE + + + + Card Style + + + Choose how Continue Watching items appear on the home screen + + + handleUpdateSetting('continueWatchingCardStyle', 'wide')} + activeOpacity={0.7} + > + + + + + + + + + + + + Wide + + {settings.continueWatchingCardStyle === 'wide' && ( + + )} + + + handleUpdateSetting('continueWatchingCardStyle', 'poster')} + activeOpacity={0.7} + > + + + + + + + + Poster + + {settings.continueWatchingCardStyle === 'poster' && ( + + )} + + + @@ -207,80 +283,80 @@ const ContinueWatchingSettingsScreen: React.FC = () => { CACHE SETTINGS - - - Stream Cache Duration - - - How long to keep cached stream links before they expire - - - {TTL_OPTIONS.map((row, rowIndex) => ( - - {row.map((option) => ( - - ))} - - ))} + + + Stream Cache Duration + + + How long to keep cached stream links before they expire + + + {TTL_OPTIONS.map((row, rowIndex) => ( + + {row.map((option) => ( + + ))} + + ))} + - )} {settings.useCachedStreams && ( - - - - Important Note + + + + Important Note + + + + Not all stream links may remain active for the full cache duration. Longer cache times may result in expired links. If a cached link fails, the app will fall back to fetching fresh streams. - - Not all stream links may remain active for the full cache duration. Longer cache times may result in expired links. If a cached link fails, the app will fall back to fetching fresh streams. - - )} - - - - How it works + + + + How it works + + + + {settings.useCachedStreams ? ( + <> + β€’ Streams are cached for your selected duration after playing{'\n'} + β€’ Cached streams are validated before use{'\n'} + β€’ If cache is invalid or expired, falls back to content screen{'\n'} + β€’ "Use Cached Streams" controls direct player vs screen navigation{'\n'} + β€’ "Open Metadata Screen" appears only when cached streams are disabled + + ) : ( + <> + β€’ When cached streams are disabled, clicking Continue Watching items opens content screens{'\n'} + β€’ "Open Metadata Screen" option controls which screen to open{'\n'} + β€’ Metadata screen shows content details and allows manual stream selection{'\n'} + β€’ Streams screen shows available streams for immediate playback + + )} - - {settings.useCachedStreams ? ( - <> - β€’ Streams are cached for your selected duration after playing{'\n'} - β€’ Cached streams are validated before use{'\n'} - β€’ If cache is invalid or expired, falls back to content screen{'\n'} - β€’ "Use Cached Streams" controls direct player vs screen navigation{'\n'} - β€’ "Open Metadata Screen" appears only when cached streams are disabled - - ) : ( - <> - β€’ When cached streams are disabled, clicking Continue Watching items opens content screens{'\n'} - β€’ "Open Metadata Screen" option controls which screen to open{'\n'} - β€’ Metadata screen shows content details and allows manual stream selection{'\n'} - β€’ Streams screen shows available streams for immediate playback - - )} - - {/* Saved indicator */} - @@ -466,6 +542,84 @@ const createStyles = (colors: any) => StyleSheet.create({ fontSize: 14, lineHeight: 20, }, + // Card Style Selector Styles + cardStyleOptionsContainer: { + flexDirection: 'row', + width: '100%', + gap: 12, + }, + cardStyleOption: { + flex: 1, + alignItems: 'center', + paddingVertical: 16, + paddingHorizontal: 12, + borderRadius: 12, + borderWidth: 1, + position: 'relative', + }, + cardPreviewWide: { + flexDirection: 'row', + width: 100, + height: 60, + borderRadius: 6, + overflow: 'hidden', + marginBottom: 8, + alignSelf: 'center', + }, + cardPreviewImage: { + width: 40, + height: '100%', + borderTopLeftRadius: 6, + borderBottomLeftRadius: 6, + }, + cardPreviewContent: { + flex: 1, + padding: 4, + justifyContent: 'space-between', + }, + cardPreviewLine: { + height: 8, + borderRadius: 2, + }, + cardPreviewProgress: { + height: 4, + borderRadius: 2, + width: '100%', + }, + cardPreviewProgressFill: { + height: '100%', + borderRadius: 2, + }, + cardPreviewPoster: { + width: 44, + height: 60, + borderRadius: 6, + overflow: 'hidden', + marginBottom: 8, + position: 'relative', + }, + cardPreviewPosterImage: { + width: '100%', + height: '100%', + borderRadius: 6, + }, + cardPreviewPosterProgress: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: 4, + }, + cardStyleLabel: { + fontSize: 14, + fontWeight: '600', + marginTop: 4, + }, + cardStyleCheck: { + position: 'absolute', + top: 8, + right: 8, + }, }); export default ContinueWatchingSettingsScreen; From 0f1d73671685f4de3ff71adf2a17c831385688a3 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 5 Jan 2026 23:42:51 +0530 Subject: [PATCH 12/14] slight onboarding screen Ui change --- ios/Podfile.lock | 32 +- ios/Podfile.properties.json | 5 +- package-lock.json | 64 ++++ package.json | 1 + src/components/onboarding/ShapeAnimation.tsx | 291 ++++++++++++++++++ src/components/onboarding/shapes/constants.ts | 8 + src/components/onboarding/shapes/cube.ts | 35 +++ src/components/onboarding/shapes/heart.ts | 35 +++ src/components/onboarding/shapes/index.ts | 28 ++ src/components/onboarding/shapes/plugin.ts | 96 ++++++ src/components/onboarding/shapes/search.ts | 57 ++++ src/components/onboarding/shapes/sphere.ts | 19 ++ src/components/onboarding/shapes/star.ts | 31 ++ src/components/onboarding/shapes/torus.ts | 48 +++ src/components/onboarding/shapes/types.ts | 1 + src/components/onboarding/shapes/utils.ts | 54 ++++ src/screens/OnboardingScreen.tsx | 91 +++++- 17 files changed, 879 insertions(+), 17 deletions(-) create mode 100644 src/components/onboarding/ShapeAnimation.tsx create mode 100644 src/components/onboarding/shapes/constants.ts create mode 100644 src/components/onboarding/shapes/cube.ts create mode 100644 src/components/onboarding/shapes/heart.ts create mode 100644 src/components/onboarding/shapes/index.ts create mode 100644 src/components/onboarding/shapes/plugin.ts create mode 100644 src/components/onboarding/shapes/search.ts create mode 100644 src/components/onboarding/shapes/sphere.ts create mode 100644 src/components/onboarding/shapes/star.ts create mode 100644 src/components/onboarding/shapes/torus.ts create mode 100644 src/components/onboarding/shapes/types.ts create mode 100644 src/components/onboarding/shapes/utils.ts diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b0ab03c..df4e4db 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1902,6 +1902,30 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - react-native-skia (2.4.14): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - react-native-slider (5.1.1): - hermes-engine - RCTRequired @@ -2822,6 +2846,7 @@ DEPENDENCIES: - react-native-google-cast (from `../node_modules/react-native-google-cast`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - "react-native-skia (from `../node_modules/@shopify/react-native-skia`)" - "react-native-slider (from `../node_modules/@react-native-community/slider`)" - react-native-video (from `../node_modules/react-native-video`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -3059,6 +3084,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-community/netinfo" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" + react-native-skia: + :path: "../node_modules/@shopify/react-native-skia" react-native-slider: :path: "../node_modules/@react-native-community/slider" react-native-video: @@ -3148,13 +3175,13 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: DisplayCriteria: - :commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3 + :commit: a7cddd878f557afa6a1f2faad9d756949406adde :git: https://github.com/kingslay/KSPlayer.git FFmpegKit: :commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9 :git: https://github.com/kingslay/FFmpegKit.git KSPlayer: - :commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3 + :commit: a7cddd878f557afa6a1f2faad9d756949406adde :git: https://github.com/kingslay/KSPlayer.git Libass: :commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9 @@ -3254,6 +3281,7 @@ SPEC CHECKSUMS: react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44 react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 + react-native-skia: 268f183f849742e9da216743ee234bd7ad81c69b react-native-slider: f954578344106f0a732a4358ce3a3e11015eb6e1 react-native-video: f5982e21efab0dc356d92541a8a9e19581307f58 React-NativeModulesApple: a9464983ccc0f66f45e93558671f60fc7536e438 diff --git a/ios/Podfile.properties.json b/ios/Podfile.properties.json index 417e2e5..42ffb6c 100644 --- a/ios/Podfile.properties.json +++ b/ios/Podfile.properties.json @@ -1,5 +1,6 @@ { "expo.jsEngine": "hermes", "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", - "newArchEnabled": "true" -} + "newArchEnabled": "true", + "ios.deploymentTarget": "16.0" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e690a85..1e28238 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@react-navigation/stack": "^7.2.10", "@sentry/react-native": "^7.6.0", "@shopify/flash-list": "^2.2.0", + "@shopify/react-native-skia": "^2.4.14", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", "axios": "^1.12.2", @@ -3642,6 +3643,33 @@ "react-native": "*" } }, + "node_modules/@shopify/react-native-skia": { + "version": "2.4.14", + "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.14.tgz", + "integrity": "sha512-zFxjAQbfrdOxoNJoaOCZQzZliuAWXjFkrNZv2PtofG2RAUPWIxWmk2J/oOROpTwXgkmh1JLvFp3uONccTXUthQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "canvaskit-wasm": "0.40.0", + "react-reconciler": "0.31.0" + }, + "bin": { + "setup-skia-web": "scripts/setup-canvaskit.js" + }, + "peerDependencies": { + "react": ">=19.0", + "react-native": ">=0.78", + "react-native-reanimated": ">=3.19.1" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + } + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -4304,6 +4332,12 @@ "url": "https://github.com/sponsors/crutchcorn" } }, + "node_modules/@webgpu/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz", + "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", + "license": "BSD-3-Clause" + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -5158,6 +5192,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvaskit-wasm": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", + "integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==", + "license": "BSD-3-Clause", + "dependencies": { + "@webgpu/types": "0.1.21" + } + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -11307,6 +11350,27 @@ "async-limiter": "~1.0.0" } }, + "node_modules/react-reconciler": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", + "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/package.json b/package.json index f6c33bd..ca955c1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@react-navigation/stack": "^7.2.10", "@sentry/react-native": "^7.6.0", "@shopify/flash-list": "^2.2.0", + "@shopify/react-native-skia": "^2.4.14", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", "axios": "^1.12.2", diff --git a/src/components/onboarding/ShapeAnimation.tsx b/src/components/onboarding/ShapeAnimation.tsx new file mode 100644 index 0000000..141a3e5 --- /dev/null +++ b/src/components/onboarding/ShapeAnimation.tsx @@ -0,0 +1,291 @@ +import React, { useEffect, useMemo } from 'react'; +import { useWindowDimensions, StyleSheet } from 'react-native'; +import { + Blur, + BlurMask, + Canvas, + Circle, + Extrapolate, + interpolate, + LinearGradient, + Path, + RadialGradient, + usePathValue, + vec, + Group, +} from '@shopify/react-native-skia'; +import { + Easing, + useSharedValue, + withRepeat, + withTiming, + SharedValue, +} from 'react-native-reanimated'; + +import { + type Point3D, + N_POINTS, + ALL_SHAPES, + ALL_SHAPES_X, + ALL_SHAPES_Y, + ALL_SHAPES_Z, +} from './shapes'; + +// Number of shapes +const SHAPES_COUNT = ALL_SHAPES.length; + +// Color palettes for each shape (gradient: start, middle, end) +const COLOR_PALETTES = [ + ['#FFD700', '#FFA500', '#FF6B00'], // Star: Gold β†’ Orange + ['#7C3AED', '#A855F7', '#EC4899'], // Plugin: Purple β†’ Pink + ['#00D9FF', '#06B6D4', '#0EA5E9'], // Search: Cyan β†’ Blue + ['#FF006E', '#F43F5E', '#FB7185'], // Heart: Pink β†’ Rose +]; + +// ============ 3D UTILITIES ============ +const rotateX = (p: Point3D, angle: number): Point3D => { + 'worklet'; + return { + x: p.x, + y: p.y * Math.cos(angle) - p.z * Math.sin(angle), + z: p.y * Math.sin(angle) + p.z * Math.cos(angle), + }; +}; + +const rotateY = (p: Point3D, angle: number): Point3D => { + 'worklet'; + return { + x: p.x * Math.cos(angle) + p.z * Math.sin(angle), + y: p.y, + z: -p.x * Math.sin(angle) + p.z * Math.cos(angle), + }; +}; + +interface ShapeAnimationProps { + scrollX: SharedValue; +} + +// Single colored path component +const ColoredPath = ({ + morphPath, + colorIndex, + scrollX, + windowWidth, + windowHeight, +}: { + morphPath: any; + colorIndex: number; + scrollX: SharedValue; + windowWidth: number; + windowHeight: number; +}) => { + const colors = COLOR_PALETTES[colorIndex]; + + // Create opacity value using Skia's interpolate inside usePathValue pattern + const opacityPath = usePathValue((skPath) => { + 'worklet'; + // Calculate opacity based on scroll position + const shapeWidth = windowWidth; + const slideStart = colorIndex * shapeWidth; + const slideMid = slideStart; + const slideEnd = (colorIndex + 1) * shapeWidth; + const prevSlideEnd = (colorIndex - 1) * shapeWidth; + + // Opacity peaks at 1 when on this slide, fades to 0 on adjacent slides + let opacity = 0; + if (colorIndex === 0) { + // First slide: 1 at start, fade out to next + opacity = interpolate( + scrollX.value, + [0, shapeWidth], + [1, 0], + Extrapolate.CLAMP + ); + } else if (colorIndex === COLOR_PALETTES.length - 1) { + // Last slide: fade in from previous, stay at 1 + opacity = interpolate( + scrollX.value, + [prevSlideEnd, slideMid], + [0, 1], + Extrapolate.CLAMP + ); + } else { + // Middle slides: fade in from previous, fade out to next + opacity = interpolate( + scrollX.value, + [prevSlideEnd, slideMid, slideEnd], + [0, 1, 0], + Extrapolate.CLAMP + ); + } + + // Store opacity in path for use - we'll read it via a trick + // This is a workaround since we can't directly animate opacity + skPath.addCircle(-1000 - opacity * 100, -1000, 1); // Hidden marker + return skPath; + }); + + return ( + + + + + + + ); +}; + +export const ShapeAnimation: React.FC = ({ scrollX }) => { + const iTime = useSharedValue(0.0); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + + // Create paths for each color layer with opacity baked in + const createPathForIndex = (colorIndex: number) => { + return usePathValue(skPath => { + 'worklet'; + const centerX = windowWidth / 2; + const centerY = windowHeight * 0.65; + const distance = 350; + + // Calculate opacity for this color layer + const shapeWidth = windowWidth; + const slideStart = colorIndex * shapeWidth; + const prevSlideEnd = (colorIndex - 1) * shapeWidth; + const nextSlideStart = (colorIndex + 1) * shapeWidth; + + let opacity = 0; + if (colorIndex === 0) { + opacity = interpolate(scrollX.value, [0, shapeWidth], [1, 0], Extrapolate.CLAMP); + } else if (colorIndex === COLOR_PALETTES.length - 1) { + opacity = interpolate(scrollX.value, [prevSlideEnd, slideStart], [0, 1], Extrapolate.CLAMP); + } else { + opacity = interpolate(scrollX.value, [prevSlideEnd, slideStart, nextSlideStart], [0, 1, 0], Extrapolate.CLAMP); + } + + // Skip drawing if not visible + if (opacity < 0.01) return skPath; + + // Input range for all shapes + const inputRange = new Array(ALL_SHAPES.length) + .fill(0) + .map((_, idx) => shapeWidth * idx); + + for (let i = 0; i < N_POINTS; i++) { + const baseX = interpolate(scrollX.value, inputRange, ALL_SHAPES_X[i], Extrapolate.CLAMP); + const baseY = interpolate(scrollX.value, inputRange, ALL_SHAPES_Y[i], Extrapolate.CLAMP); + const baseZ = interpolate(scrollX.value, inputRange, ALL_SHAPES_Z[i], Extrapolate.CLAMP); + + let p: Point3D = { x: baseX, y: baseY, z: baseZ }; + p = rotateX(p, 0.2); + p = rotateY(p, iTime.value); + + const scale = distance / (distance + p.z); + const screenX = centerX + p.x * scale; + const screenY = centerY + p.y * scale; + + // Scale radius by opacity for smooth color transition + const radius = Math.max(0.1, 0.5 * scale * opacity); + skPath.addCircle(screenX, screenY, radius); + } + + return skPath; + }); + }; + + // Create all 4 color layer paths + const path0 = createPathForIndex(0); + const path1 = createPathForIndex(1); + const path2 = createPathForIndex(2); + const path3 = createPathForIndex(3); + + // Rotation animation + useEffect(() => { + iTime.value = 0; + iTime.value = withRepeat( + withTiming(2 * Math.PI, { + duration: 12000, + easing: Easing.linear, + }), + -1, + false + ); + }, []); + + return ( + + {/* Background radial gradient blurred */} + + + + + + {/* Layer 0: Gold (Star) */} + + + + + + {/* Layer 1: Purple (Plugin) */} + + + + + + {/* Layer 2: Cyan (Search) */} + + + + + + {/* Layer 3: Pink (Heart) */} + + + + + + ); +}; + +const styles = StyleSheet.create({ + canvas: { + position: 'absolute', + top: 0, + left: 0, + }, +}); + +export default ShapeAnimation; diff --git a/src/components/onboarding/shapes/constants.ts b/src/components/onboarding/shapes/constants.ts new file mode 100644 index 0000000..dc0e399 --- /dev/null +++ b/src/components/onboarding/shapes/constants.ts @@ -0,0 +1,8 @@ +// Fixed number of points for all shapes (for interpolation) +// Lower = better FPS, 1000 points is a good balance for smooth 60fps +export const N_POINTS = 1000; + +export const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2; + +// Normalize a shape to have height TARGET_HEIGHT +export const TARGET_HEIGHT = 200; diff --git a/src/components/onboarding/shapes/cube.ts b/src/components/onboarding/shapes/cube.ts new file mode 100644 index 0000000..3d037db --- /dev/null +++ b/src/components/onboarding/shapes/cube.ts @@ -0,0 +1,35 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { fibonacciPoint, normalizeShape, scaleShape } from './utils'; + +// Cube - map sphere to cube +const generateCubePoints = (size: number): Point3D[] => { + const points: Point3D[] = []; + const s = size / 2; + + for (let i = 0; i < N_POINTS; i++) { + const { theta, phi } = fibonacciPoint(i, N_POINTS); + // Point on unit sphere + const sx = Math.sin(phi) * Math.cos(theta); + const sy = Math.sin(phi) * Math.sin(theta); + const sz = Math.cos(phi); + + // Map to cube (cube mapping) + const absX = Math.abs(sx); + const absY = Math.abs(sy); + const absZ = Math.abs(sz); + const max = Math.max(absX, absY, absZ); + + points.push({ + x: (sx / max) * s, + y: (sy / max) * s, + z: (sz / max) * s, + }); + } + return points; +}; + +export const CUBE_POINTS = scaleShape( + normalizeShape(generateCubePoints(150)), + 0.75, +); diff --git a/src/components/onboarding/shapes/heart.ts b/src/components/onboarding/shapes/heart.ts new file mode 100644 index 0000000..9f66376 --- /dev/null +++ b/src/components/onboarding/shapes/heart.ts @@ -0,0 +1,35 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { fibonacciPoint, normalizeShape } from './utils'; + +// Heart - starts from Fibonacci sphere, deforms into heart +const generateHeartPoints = (scale: number): Point3D[] => { + const points: Point3D[] = []; + for (let i = 0; i < N_POINTS; i++) { + const { theta, phi } = fibonacciPoint(i, N_POINTS); + + // Use same angular coordinates as sphere + const u = theta; + const v = phi; + const sinV = Math.sin(v); + + // Heart surface with same angular correspondence + const hx = sinV * (15 * Math.sin(u) - 4 * Math.sin(3 * u)); + const hz = 8 * Math.cos(v); + const hy = + sinV * + (15 * Math.cos(u) - + 5 * Math.cos(2 * u) - + 2 * Math.cos(3 * u) - + Math.cos(4 * u)); + + points.push({ + x: hx * scale * 0.06, + y: -hy * scale * 0.06, + z: hz * scale * 0.06, + }); + } + return points; +}; + +export const HEART_POINTS = normalizeShape(generateHeartPoints(120)); diff --git a/src/components/onboarding/shapes/index.ts b/src/components/onboarding/shapes/index.ts new file mode 100644 index 0000000..e8c0c9d --- /dev/null +++ b/src/components/onboarding/shapes/index.ts @@ -0,0 +1,28 @@ +export { type Point3D } from './types'; +export { N_POINTS } from './constants'; + +import { N_POINTS } from './constants'; +import { STAR_POINTS } from './star'; // Welcome to Nuvio +import { PLUGIN_POINTS } from './plugin'; // Powerful Addons +import { SEARCH_POINTS } from './search'; // Smart Discovery +import { HEART_POINTS } from './heart'; // Your Library (favorites) + +// Array of all shapes - ordered to match onboarding slides +export const ALL_SHAPES = [ + STAR_POINTS, // Slide 1: Welcome + PLUGIN_POINTS, // Slide 2: Addons + SEARCH_POINTS, // Slide 3: Discovery + HEART_POINTS, // Slide 4: Library +]; + +export const POINTS_ARRAY = new Array(N_POINTS).fill(0); + +export const ALL_SHAPES_X = POINTS_ARRAY.map((_, pointIndex) => + ALL_SHAPES.map(shape => shape[pointIndex].x), +); +export const ALL_SHAPES_Y = POINTS_ARRAY.map((_, pointIndex) => + ALL_SHAPES.map(shape => shape[pointIndex].y), +); +export const ALL_SHAPES_Z = POINTS_ARRAY.map((_, pointIndex) => + ALL_SHAPES.map(shape => shape[pointIndex].z), +); diff --git a/src/components/onboarding/shapes/plugin.ts b/src/components/onboarding/shapes/plugin.ts new file mode 100644 index 0000000..9cf066f --- /dev/null +++ b/src/components/onboarding/shapes/plugin.ts @@ -0,0 +1,96 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { normalizeShape, scaleShape } from './utils'; + +// LEGO Brick shape - perfectly represents "Addons" or "Plugins" +const generateLegoPoints = (): Point3D[] => { + const points: Point3D[] = []; + + // Dimensions + const width = 160; + const depth = 80; + const height = 48; + const studRadius = 12; + const studHeight = 16; + + // Distribute points: 70% body, 30% studs + const bodyPoints = Math.floor(N_POINTS * 0.7); + const studPoints = N_POINTS - bodyPoints; + const pointsPerStud = Math.floor(studPoints / 8); // 8 studs (2x4 brick) + + // 1. Main Brick Body (Rectangular Prism) + for (let i = 0; i < bodyPoints; i++) { + const t1 = Math.random(); + const t2 = Math.random(); + const t3 = Math.random(); + + // Create density concentration on edges for better definition + const x = (Math.pow(t1, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * width / 2; + const y = (Math.pow(t2, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * height / 2; + const z = (Math.pow(t3, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * depth / 2; + + // Snapping to faces to make it look solid + const face = Math.floor(Math.random() * 6); + let px = x, py = y, pz = z; + + if (face === 0) px = width / 2; + else if (face === 1) px = -width / 2; + else if (face === 2) py = height / 2; + else if (face === 3) py = -height / 2; + else if (face === 4) pz = depth / 2; + else if (face === 5) pz = -depth / 2; + + // Add some random noise inside/surface + if (Math.random() > 0.8) { + points.push({ x: x, y: y, z: z }); + } else { + points.push({ x: px, y: py, z: pz }); + } + } + + // 2. Studs (Cylinders on top) + // 2x4 Grid positions + const studPositions = [ + { x: -width * 0.375, z: -depth * 0.25 }, { x: -width * 0.125, z: -depth * 0.25 }, + { x: width * 0.125, z: -depth * 0.25 }, { x: width * 0.375, z: -depth * 0.25 }, + { x: -width * 0.375, z: depth * 0.25 }, { x: -width * 0.125, z: depth * 0.25 }, + { x: width * 0.125, z: depth * 0.25 }, { x: width * 0.375, z: depth * 0.25 }, + ]; + + studPositions.forEach((pos, studIndex) => { + for (let j = 0; j < pointsPerStud; j++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * studRadius; + + // Top face of stud + if (Math.random() > 0.5) { + points.push({ + x: pos.x + r * Math.cos(angle), + y: -height / 2 - studHeight, // Top + z: pos.z + r * Math.sin(angle), + }); + } else { + // Side of stud + const h = Math.random() * studHeight; + points.push({ + x: pos.x + studRadius * Math.cos(angle), + y: -height / 2 - h, + z: pos.z + studRadius * Math.sin(angle), + }); + } + } + }); + + // FILL remaining points to prevent "undefined" errors + while (points.length < N_POINTS) { + points.push(points[points.length - 1] || { x: 0, y: 0, z: 0 }); + } + + // Slice to guarantee exact count + return points.slice(0, N_POINTS); +}; + +export const PLUGIN_POINTS = scaleShape( + normalizeShape(generateLegoPoints()), + 0.4, +); diff --git a/src/components/onboarding/shapes/search.ts b/src/components/onboarding/shapes/search.ts new file mode 100644 index 0000000..1ee39ca --- /dev/null +++ b/src/components/onboarding/shapes/search.ts @@ -0,0 +1,57 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { normalizeShape, scaleShape } from './utils'; + +// Magnifying glass/search shape - for "Discovery" page +const generateSearchPoints = (radius: number): Point3D[] => { + const points: Point3D[] = []; + const handleLength = radius * 0.8; + const handleWidth = radius * 0.15; + + // Split points between ring and handle + const ringPoints = Math.floor(N_POINTS * 0.7); + const handlePoints = N_POINTS - ringPoints; + + // Create the circular ring (lens) + for (let i = 0; i < ringPoints; i++) { + const t = i / ringPoints; + const mainAngle = t * Math.PI * 2; + const tubeAngle = (i * 17) % 20 / 20 * Math.PI * 2; // Distribute around tube + + const tubeRadius = radius * 0.12; + const centerRadius = radius; + + const cx = centerRadius * Math.cos(mainAngle); + const cy = centerRadius * Math.sin(mainAngle); + + points.push({ + x: cx + tubeRadius * Math.cos(tubeAngle) * Math.cos(mainAngle), + y: cy + tubeRadius * Math.cos(tubeAngle) * Math.sin(mainAngle), + z: tubeRadius * Math.sin(tubeAngle), + }); + } + + // Create the handle + for (let i = 0; i < handlePoints; i++) { + const t = i / handlePoints; + const handleAngle = (i * 13) % 12 / 12 * Math.PI * 2; + + // Handle position (extends from bottom-right of ring) + const handleStart = radius * 0.7; + const hx = handleStart + t * handleLength; + const hy = handleStart + t * handleLength; + + points.push({ + x: hx + handleWidth * Math.cos(handleAngle) * 0.3, + y: hy + handleWidth * Math.cos(handleAngle) * 0.3, + z: handleWidth * Math.sin(handleAngle), + }); + } + + return points; +}; + +export const SEARCH_POINTS = scaleShape( + normalizeShape(generateSearchPoints(80)), + 1.0, +); diff --git a/src/components/onboarding/shapes/sphere.ts b/src/components/onboarding/shapes/sphere.ts new file mode 100644 index 0000000..82a6a13 --- /dev/null +++ b/src/components/onboarding/shapes/sphere.ts @@ -0,0 +1,19 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { fibonacciPoint, normalizeShape } from './utils'; + +// Sphere +const generateSpherePoints = (radius: number): Point3D[] => { + const points: Point3D[] = []; + for (let i = 0; i < N_POINTS; i++) { + const { theta, phi } = fibonacciPoint(i, N_POINTS); + points.push({ + x: radius * Math.sin(phi) * Math.cos(theta), + y: radius * Math.sin(phi) * Math.sin(theta), + z: radius * Math.cos(phi), + }); + } + return points; +}; + +export const SPHERE_POINTS = normalizeShape(generateSpherePoints(100)); diff --git a/src/components/onboarding/shapes/star.ts b/src/components/onboarding/shapes/star.ts new file mode 100644 index 0000000..dfb13d4 --- /dev/null +++ b/src/components/onboarding/shapes/star.ts @@ -0,0 +1,31 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { fibonacciPoint, normalizeShape, scaleShape } from './utils'; + +// Star shape - for "Welcome" page +const generateStarPoints = (outerRadius: number, innerRadius: number): Point3D[] => { + const points: Point3D[] = []; + const numPoints = 5; // 5-pointed star + + for (let i = 0; i < N_POINTS; i++) { + const { theta, phi, t } = fibonacciPoint(i, N_POINTS); + + // Create star cross-section + const angle = theta * numPoints; + const radiusFactor = 0.5 + 0.5 * Math.cos(angle); + const radius = innerRadius + (outerRadius - innerRadius) * radiusFactor; + + const sinPhi = Math.sin(phi); + points.push({ + x: radius * sinPhi * Math.cos(theta), + y: radius * sinPhi * Math.sin(theta), + z: radius * Math.cos(phi) * 0.3, // Flatten z for star shape + }); + } + return points; +}; + +export const STAR_POINTS = scaleShape( + normalizeShape(generateStarPoints(100, 40)), + 0.9, +); diff --git a/src/components/onboarding/shapes/torus.ts b/src/components/onboarding/shapes/torus.ts new file mode 100644 index 0000000..6146f9d --- /dev/null +++ b/src/components/onboarding/shapes/torus.ts @@ -0,0 +1,48 @@ +import { N_POINTS } from './constants'; +import { type Point3D } from './types'; +import { normalizeShape, scaleShape } from './utils'; + +// Torus - uniform grid with same index correspondence +const generateTorusPoints = (major: number, minor: number): Point3D[] => { + const points: Point3D[] = []; + + // Calculate approximate grid dimensions + const ratio = major / minor; + const minorSegments = Math.round(Math.sqrt(N_POINTS / ratio)); + const majorSegments = Math.round(N_POINTS / minorSegments); + + let idx = 0; + for (let i = 0; i < majorSegments && idx < N_POINTS; i++) { + const u = (i / majorSegments) * Math.PI * 2; + + for (let j = 0; j < minorSegments && idx < N_POINTS; j++) { + const v = (j / minorSegments) * Math.PI * 2; + + points.push({ + x: (major + minor * Math.cos(v)) * Math.cos(u), + y: (major + minor * Math.cos(v)) * Math.sin(u), + z: minor * Math.sin(v), + }); + idx++; + } + } + + // Fill missing points if necessary + while (points.length < N_POINTS) { + const t = points.length / N_POINTS; + const u = t * Math.PI * 2 * majorSegments; + const v = t * Math.PI * 2 * minorSegments; + points.push({ + x: (major + minor * Math.cos(v)) * Math.cos(u), + y: (major + minor * Math.cos(v)) * Math.sin(u), + z: minor * Math.sin(v), + }); + } + + return points.slice(0, N_POINTS); +}; + +export const TORUS_POINTS = scaleShape( + normalizeShape(generateTorusPoints(50, 25)), + 1.2, +); diff --git a/src/components/onboarding/shapes/types.ts b/src/components/onboarding/shapes/types.ts new file mode 100644 index 0000000..d349cec --- /dev/null +++ b/src/components/onboarding/shapes/types.ts @@ -0,0 +1 @@ +export type Point3D = { x: number; y: number; z: number }; diff --git a/src/components/onboarding/shapes/utils.ts b/src/components/onboarding/shapes/utils.ts new file mode 100644 index 0000000..c1a68f8 --- /dev/null +++ b/src/components/onboarding/shapes/utils.ts @@ -0,0 +1,54 @@ +import { GOLDEN_RATIO, TARGET_HEIGHT } from './constants'; +import { type Point3D } from './types'; + +// Generate Fibonacci points on unit sphere, then map to shape +export const fibonacciPoint = ( + i: number, + total: number, +): { theta: number; phi: number; t: number } => { + const t = i / total; + const theta = (2 * Math.PI * i) / GOLDEN_RATIO; + const phi = Math.acos(1 - 2 * t); + return { theta, phi, t }; +}; + +export const normalizeShape = (points: Point3D[]): Point3D[] => { + // Find min/max for each axis + let minX = Infinity, + maxX = -Infinity; + let minY = Infinity, + maxY = -Infinity; + let minZ = Infinity, + maxZ = -Infinity; + + for (const p of points) { + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + minY = Math.min(minY, p.y); + maxY = Math.max(maxY, p.y); + minZ = Math.min(minZ, p.z); + maxZ = Math.max(maxZ, p.z); + } + + // Calculate current dimensions + const currentHeight = maxY - minY; + const scale = TARGET_HEIGHT / currentHeight; + + // Center and scale uniformly + const centerY = (minY + maxY) / 2; + + return points.map(p => ({ + x: (p.x - (minX + maxX) / 2) * scale, + y: (p.y - centerY) * scale, + z: (p.z - (minZ + maxZ) / 2) * scale, + })); +}; + +// Additional scale for single shape +export const scaleShape = (points: Point3D[], factor: number): Point3D[] => { + return points.map(p => ({ + x: p.x * factor, + y: p.y * factor, + z: p.z * factor, + })); +}; diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index fc68202..f807ea5 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -25,6 +25,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { mmkvStorage } from '../services/mmkvStorage'; +import { ShapeAnimation } from '../components/onboarding/ShapeAnimation'; const { width, height } = Dimensions.get('window'); @@ -263,6 +264,29 @@ const OnboardingScreen = () => { transform: [{ scale: buttonScale.value }], })); + // Animated opacity for button and swipe indicator based on scroll + const lastSlideStart = (onboardingData.length - 1) * width; + + const buttonOpacityStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollX.value, + [lastSlideStart - width * 0.3, lastSlideStart], + [0, 1], + Extrapolation.CLAMP + ); + return { opacity }; + }); + + const swipeOpacityStyle = useAnimatedStyle(() => { + const opacity = interpolate( + scrollX.value, + [lastSlideStart - width * 0.3, lastSlideStart], + [1, 0], + Extrapolation.CLAMP + ); + return { opacity }; + }); + const handlePressIn = () => { buttonScale.value = withSpring(0.95, { damping: 15, stiffness: 400 }); }; @@ -276,6 +300,9 @@ const OnboardingScreen = () => { + {/* Shape Animation Background */} + + {/* Header */} { ))} - {/* Animated Button */} - - - - {currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Continue'} - + {/* Button and Swipe indicator with crossfade based on scroll */} + + {/* Swipe Indicator - fades out on last slide */} + + Swipe to continue + β†’ - + + {/* Get Started Button - fades in on last slide */} + + + + Get Started + + + + @@ -381,8 +417,9 @@ const styles = StyleSheet.create({ slide: { width, flex: 1, - justifyContent: 'center', + justifyContent: 'flex-start', // Align to top paddingHorizontal: 32, + paddingTop: '20%', // Push text down slightly from header }, textContainer: { alignItems: 'flex-start', @@ -437,6 +474,34 @@ const styles = StyleSheet.create({ color: '#0A0A0A', letterSpacing: 0.3, }, + swipeIndicator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 18, + gap: 8, + }, + swipeText: { + fontSize: 14, + fontWeight: '500', + color: 'rgba(255, 255, 255, 0.4)', + letterSpacing: 0.3, + }, + swipeArrow: { + fontSize: 18, + color: 'rgba(255, 255, 255, 0.4)', + }, + footerButtonContainer: { + height: 56, + position: 'relative', + }, + absoluteFill: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, }); export default OnboardingScreen; \ No newline at end of file From 4ce14ec4cc0c18cd2fe9b21b2285ce3b9dbc5332 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 6 Jan 2026 00:00:33 +0530 Subject: [PATCH 13/14] optimized perf --- src/components/onboarding/ShapeAnimation.tsx | 219 +++++-------------- src/screens/OnboardingScreen.tsx | 11 +- 2 files changed, 56 insertions(+), 174 deletions(-) diff --git a/src/components/onboarding/ShapeAnimation.tsx b/src/components/onboarding/ShapeAnimation.tsx index 141a3e5..6a5c9d5 100644 --- a/src/components/onboarding/ShapeAnimation.tsx +++ b/src/components/onboarding/ShapeAnimation.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; import { useWindowDimensions, StyleSheet } from 'react-native'; import { Blur, @@ -7,12 +7,12 @@ import { Circle, Extrapolate, interpolate, + interpolateColors, LinearGradient, Path, RadialGradient, usePathValue, vec, - Group, } from '@shopify/react-native-skia'; import { Easing, @@ -20,6 +20,7 @@ import { withRepeat, withTiming, SharedValue, + useDerivedValue, } from 'react-native-reanimated'; import { @@ -31,15 +32,12 @@ import { ALL_SHAPES_Z, } from './shapes'; -// Number of shapes -const SHAPES_COUNT = ALL_SHAPES.length; - -// Color palettes for each shape (gradient: start, middle, end) -const COLOR_PALETTES = [ - ['#FFD700', '#FFA500', '#FF6B00'], // Star: Gold β†’ Orange - ['#7C3AED', '#A855F7', '#EC4899'], // Plugin: Purple β†’ Pink - ['#00D9FF', '#06B6D4', '#0EA5E9'], // Search: Cyan β†’ Blue - ['#FF006E', '#F43F5E', '#FB7185'], // Heart: Pink β†’ Rose +// Color palettes for each shape (gradient stops) +const COLOR_STOPS = [ + { start: '#FFD700', end: '#FF6B00' }, // Star: Gold β†’ Orange + { start: '#7C3AED', end: '#EC4899' }, // Plugin: Purple β†’ Pink + { start: '#00D9FF', end: '#0EA5E9' }, // Search: Cyan β†’ Blue + { start: '#FF006E', end: '#FB7185' }, // Heart: Pink β†’ Rose ]; // ============ 3D UTILITIES ============ @@ -65,144 +63,57 @@ interface ShapeAnimationProps { scrollX: SharedValue; } -// Single colored path component -const ColoredPath = ({ - morphPath, - colorIndex, - scrollX, - windowWidth, - windowHeight, -}: { - morphPath: any; - colorIndex: number; - scrollX: SharedValue; - windowWidth: number; - windowHeight: number; -}) => { - const colors = COLOR_PALETTES[colorIndex]; - - // Create opacity value using Skia's interpolate inside usePathValue pattern - const opacityPath = usePathValue((skPath) => { - 'worklet'; - // Calculate opacity based on scroll position - const shapeWidth = windowWidth; - const slideStart = colorIndex * shapeWidth; - const slideMid = slideStart; - const slideEnd = (colorIndex + 1) * shapeWidth; - const prevSlideEnd = (colorIndex - 1) * shapeWidth; - - // Opacity peaks at 1 when on this slide, fades to 0 on adjacent slides - let opacity = 0; - if (colorIndex === 0) { - // First slide: 1 at start, fade out to next - opacity = interpolate( - scrollX.value, - [0, shapeWidth], - [1, 0], - Extrapolate.CLAMP - ); - } else if (colorIndex === COLOR_PALETTES.length - 1) { - // Last slide: fade in from previous, stay at 1 - opacity = interpolate( - scrollX.value, - [prevSlideEnd, slideMid], - [0, 1], - Extrapolate.CLAMP - ); - } else { - // Middle slides: fade in from previous, fade out to next - opacity = interpolate( - scrollX.value, - [prevSlideEnd, slideMid, slideEnd], - [0, 1, 0], - Extrapolate.CLAMP - ); - } - - // Store opacity in path for use - we'll read it via a trick - // This is a workaround since we can't directly animate opacity - skPath.addCircle(-1000 - opacity * 100, -1000, 1); // Hidden marker - return skPath; - }); - - return ( - - - - - - - ); -}; - export const ShapeAnimation: React.FC = ({ scrollX }) => { const iTime = useSharedValue(0.0); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); - // Create paths for each color layer with opacity baked in - const createPathForIndex = (colorIndex: number) => { - return usePathValue(skPath => { - 'worklet'; - const centerX = windowWidth / 2; - const centerY = windowHeight * 0.65; - const distance = 350; + // Pre-compute input range once + const shapeWidth = windowWidth; + const inputRange = ALL_SHAPES.map((_, idx) => shapeWidth * idx); - // Calculate opacity for this color layer - const shapeWidth = windowWidth; - const slideStart = colorIndex * shapeWidth; - const prevSlideEnd = (colorIndex - 1) * shapeWidth; - const nextSlideStart = (colorIndex + 1) * shapeWidth; + // Single optimized path - all 4 shapes batched into one Skia Path + const morphPath = usePathValue(skPath => { + 'worklet'; + const centerX = windowWidth / 2; + const centerY = windowHeight * 0.65; + const distance = 350; - let opacity = 0; - if (colorIndex === 0) { - opacity = interpolate(scrollX.value, [0, shapeWidth], [1, 0], Extrapolate.CLAMP); - } else if (colorIndex === COLOR_PALETTES.length - 1) { - opacity = interpolate(scrollX.value, [prevSlideEnd, slideStart], [0, 1], Extrapolate.CLAMP); - } else { - opacity = interpolate(scrollX.value, [prevSlideEnd, slideStart, nextSlideStart], [0, 1, 0], Extrapolate.CLAMP); - } + for (let i = 0; i < N_POINTS; i++) { + // Interpolate 3D coordinates between all shapes + const baseX = interpolate(scrollX.value, inputRange, ALL_SHAPES_X[i], Extrapolate.CLAMP); + const baseY = interpolate(scrollX.value, inputRange, ALL_SHAPES_Y[i], Extrapolate.CLAMP); + const baseZ = interpolate(scrollX.value, inputRange, ALL_SHAPES_Z[i], Extrapolate.CLAMP); - // Skip drawing if not visible - if (opacity < 0.01) return skPath; + // Apply 3D rotation + let p: Point3D = { x: baseX, y: baseY, z: baseZ }; + p = rotateX(p, 0.2); // Fixed X tilt + p = rotateY(p, iTime.value); // Animated Y rotation - // Input range for all shapes - const inputRange = new Array(ALL_SHAPES.length) - .fill(0) - .map((_, idx) => shapeWidth * idx); + // Perspective projection + const scale = distance / (distance + p.z); + const screenX = centerX + p.x * scale; + const screenY = centerY + p.y * scale; - for (let i = 0; i < N_POINTS; i++) { - const baseX = interpolate(scrollX.value, inputRange, ALL_SHAPES_X[i], Extrapolate.CLAMP); - const baseY = interpolate(scrollX.value, inputRange, ALL_SHAPES_Y[i], Extrapolate.CLAMP); - const baseZ = interpolate(scrollX.value, inputRange, ALL_SHAPES_Z[i], Extrapolate.CLAMP); + // Depth-based radius for parallax effect + const radius = Math.max(0.2, 0.5 * scale); + skPath.addCircle(screenX, screenY, radius); + } - let p: Point3D = { x: baseX, y: baseY, z: baseZ }; - p = rotateX(p, 0.2); - p = rotateY(p, iTime.value); + return skPath; + }); - const scale = distance / (distance + p.z); - const screenX = centerX + p.x * scale; - const screenY = centerY + p.y * scale; + // Interpolate gradient colors based on scroll position + const gradientColors = useDerivedValue(() => { + const startColors = COLOR_STOPS.map(c => c.start); + const endColors = COLOR_STOPS.map(c => c.end); - // Scale radius by opacity for smooth color transition - const radius = Math.max(0.1, 0.5 * scale * opacity); - skPath.addCircle(screenX, screenY, radius); - } + const start = interpolateColors(scrollX.value, inputRange, startColors); + const end = interpolateColors(scrollX.value, inputRange, endColors); - return skPath; - }); - }; + return [start, end]; + }); - // Create all 4 color layer paths - const path0 = createPathForIndex(0); - const path1 = createPathForIndex(1); - const path2 = createPathForIndex(2); - const path3 = createPathForIndex(3); - - // Rotation animation + // Rotation animation - infinite loop useEffect(() => { iTime.value = 0; iTime.value = withRepeat( @@ -224,7 +135,7 @@ export const ShapeAnimation: React.FC = ({ scrollX }) => { height: windowHeight, }, ]}> - {/* Background radial gradient blurred */} + {/* Background glow */} = ({ scrollX }) => { - {/* Layer 0: Gold (Star) */} - + {/* Single optimized path with interpolated gradient */} + - - - - {/* Layer 1: Purple (Plugin) */} - - - - - - {/* Layer 2: Cyan (Search) */} - - - - - - {/* Layer 3: Pink (Heart) */} - - diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index f807ea5..10016c0 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -300,8 +300,8 @@ const OnboardingScreen = () => { - {/* Shape Animation Background */} - + {/* Shape Animation Background - iOS only */} + {Platform.OS === 'ios' && } {/* Header */} Date: Tue, 6 Jan 2026 00:12:00 +0530 Subject: [PATCH 14/14] fix: added timeout for tabletstreamscreen to prevent blackscreen until backdrop is fetched --- src/components/TabletStreamsLayout.tsx | 95 +++++++++++++++++--------- 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/src/components/TabletStreamsLayout.tsx b/src/components/TabletStreamsLayout.tsx index 6a05b27..85c21a3 100644 --- a/src/components/TabletStreamsLayout.tsx +++ b/src/components/TabletStreamsLayout.tsx @@ -12,9 +12,9 @@ import { LinearGradient } from 'expo-linear-gradient'; import FastImage from '@d11/react-native-fast-image'; import { MaterialIcons } from '@expo/vector-icons'; import { BlurView as ExpoBlurView } from 'expo-blur'; -import Animated, { - useSharedValue, - useAnimatedStyle, +import Animated, { + useSharedValue, + useAnimatedStyle, withTiming, withDelay, Easing @@ -44,36 +44,36 @@ interface TabletStreamsLayoutProps { metadata?: any; type: string; currentEpisode?: any; - + // Movie logo props movieLogoError: boolean; setMovieLogoError: (error: boolean) => void; - + // Stream-related props streamsEmpty: boolean; selectedProvider: string; filterItems: Array<{ id: string; name: string; }>; handleProviderChange: (provider: string) => void; activeFetchingScrapers: string[]; - + // Loading states isAutoplayWaiting: boolean; autoplayTriggered: boolean; showNoSourcesError: boolean; showInitialLoading: boolean; showStillFetching: boolean; - + // Stream rendering props sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null>; renderSectionHeader: ({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => React.ReactElement; handleStreamPress: (stream: Stream) => void; openAlert: (title: string, message: string) => void; - + // Settings and theme settings: any; currentTheme: any; colors: any; - + // Other props navigation: RootStackNavigationProp; insets: any; @@ -122,19 +122,19 @@ const TabletStreamsLayout: React.FC = ({ hasStremioStreamProviders, }) => { const styles = React.useMemo(() => createStyles(colors), [colors]); - + // Animation values for backdrop entrance const backdropOpacity = useSharedValue(0); const backdropScale = useSharedValue(1.05); const [backdropLoaded, setBackdropLoaded] = useState(false); const [backdropError, setBackdropError] = useState(false); - + // Animation values for content panels const leftPanelOpacity = useSharedValue(0); const leftPanelTranslateX = useSharedValue(-30); const rightPanelOpacity = useSharedValue(0); const rightPanelTranslateX = useSharedValue(30); - + // Get the backdrop source - prioritize episode thumbnail, then show backdrop, then poster // For episodes without thumbnails, use show's backdrop instead of poster const backdropSource = React.useMemo(() => { @@ -148,7 +148,7 @@ const TabletStreamsLayout: React.FC = ({ backdropError }); } - + // If episodeImage failed to load, skip it and use backdrop if (backdropError && episodeImage && episodeImage !== metadata?.poster) { if (__DEV__) console.log('[TabletStreamsLayout] Episode thumbnail failed, falling back to backdrop'); @@ -157,26 +157,55 @@ const TabletStreamsLayout: React.FC = ({ return { uri: bannerImage }; } } - + // If episodeImage exists and is not the same as poster, use it (real episode thumbnail) if (episodeImage && episodeImage !== metadata?.poster && !backdropError) { if (__DEV__) console.log('[TabletStreamsLayout] Using episode thumbnail:', episodeImage); return { uri: episodeImage }; } - + // If episodeImage is the same as poster (fallback case), prioritize backdrop if (bannerImage) { if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop:', bannerImage); return { uri: bannerImage }; } - + // No fallback to poster images - + if (__DEV__) console.log('[TabletStreamsLayout] No backdrop source found'); return undefined; }, [episodeImage, bannerImage, metadata?.poster, backdropError]); - - // Animate backdrop when it loads, or animate content immediately if no backdrop + + + useEffect(() => { + if (backdropSource?.uri && !backdropLoaded && !backdropError) { + + const timeoutId = setTimeout(() => { + + leftPanelOpacity.value = withTiming(1, { + duration: 600, + easing: Easing.out(Easing.cubic) + }); + leftPanelTranslateX.value = withTiming(0, { + duration: 600, + easing: Easing.out(Easing.cubic) + }); + + rightPanelOpacity.value = withDelay(200, withTiming(1, { + duration: 600, + easing: Easing.out(Easing.cubic) + })); + rightPanelTranslateX.value = withDelay(200, withTiming(0, { + duration: 600, + easing: Easing.out(Easing.cubic) + })); + }, 1000); + + return () => clearTimeout(timeoutId); + } + }, [backdropSource?.uri, backdropLoaded, backdropError]); + + useEffect(() => { if (backdropSource?.uri && backdropLoaded) { // Animate backdrop first @@ -188,7 +217,7 @@ const TabletStreamsLayout: React.FC = ({ duration: 1000, easing: Easing.out(Easing.cubic) }); - + // Animate content panels with delay after backdrop starts loading leftPanelOpacity.value = withDelay(300, withTiming(1, { duration: 600, @@ -198,7 +227,7 @@ const TabletStreamsLayout: React.FC = ({ duration: 600, easing: Easing.out(Easing.cubic) })); - + rightPanelOpacity.value = withDelay(500, withTiming(1, { duration: 600, easing: Easing.out(Easing.cubic) @@ -217,7 +246,7 @@ const TabletStreamsLayout: React.FC = ({ duration: 600, easing: Easing.out(Easing.cubic) }); - + rightPanelOpacity.value = withDelay(200, withTiming(1, { duration: 600, easing: Easing.out(Easing.cubic) @@ -228,7 +257,7 @@ const TabletStreamsLayout: React.FC = ({ })); } }, [backdropSource?.uri, backdropLoaded, backdropError]); - + // Reset animation when episode changes useEffect(() => { backdropOpacity.value = 0; @@ -240,28 +269,28 @@ const TabletStreamsLayout: React.FC = ({ setBackdropLoaded(false); setBackdropError(false); }, [episodeImage]); - + // Animated styles for backdrop const backdropAnimatedStyle = useAnimatedStyle(() => ({ opacity: backdropOpacity.value, transform: [{ scale: backdropScale.value }], })); - + // Animated styles for content panels const leftPanelAnimatedStyle = useAnimatedStyle(() => ({ opacity: leftPanelOpacity.value, transform: [{ translateX: leftPanelTranslateX.value }], })); - + const rightPanelAnimatedStyle = useAnimatedStyle(() => ({ opacity: rightPanelOpacity.value, transform: [{ translateX: rightPanelTranslateX.value }], })); - + const handleBackdropLoad = () => { setBackdropLoaded(true); }; - + const handleBackdropError = () => { if (__DEV__) console.log('[TabletStreamsLayout] Backdrop image failed to load:', backdropSource?.uri); setBackdropError(true); @@ -294,8 +323,8 @@ const TabletStreamsLayout: React.FC = ({ {isAutoplayWaiting ? 'Finding best stream for autoplay...' : - showStillFetching ? 'Still fetching streams…' : - 'Finding available streams...'} + showStillFetching ? 'Still fetching streams…' : + 'Finding available streams...'} ); @@ -311,7 +340,7 @@ const TabletStreamsLayout: React.FC = ({ // Flatten sections into a single list with header items type ListItem = { type: 'header'; title: string; addonId: string } | { type: 'stream'; stream: Stream; index: number }; - + const flatListData: ListItem[] = []; sections .filter(Boolean) @@ -327,7 +356,7 @@ const TabletStreamsLayout: React.FC = ({ if (item.type === 'header') { return renderSectionHeader({ section: { title: item.title, addonId: item.addonId } }); } - + const stream = item.stream; return ( = ({ locations={[0, 0.5, 1]} style={styles.tabletFullScreenGradient} /> - + {/* Left Panel: Movie Logo/Episode Info */} {type === 'movie' && metadata ? (