From 668099b5426ed0ed00624a3aac8ba50e75693e0e Mon Sep 17 00:00:00 2001 From: tapframe Date: Fri, 21 Nov 2025 16:09:54 +0530 Subject: [PATCH 01/15] hdr issue fix ios --- ios/KSPlayerView.swift | 42 +++-------------- src/components/home/ContentItem.tsx | 4 +- src/components/metadata/HeroSection.tsx | 60 +++++++++++++------------ src/hooks/useMetadata.ts | 3 +- src/services/catalogService.ts | 12 ++++- 5 files changed, 52 insertions(+), 69 deletions(-) diff --git a/ios/KSPlayerView.swift b/ios/KSPlayerView.swift index 19469e77..cda64d56 100644 --- a/ios/KSPlayerView.swift +++ b/ios/KSPlayerView.swift @@ -385,9 +385,11 @@ class KSPlayerView: UIView { options.asynchronousDecompression = true #endif - // PERFORMANCE OPTIMIZATION: Native HDR processing - // Set destination dynamic range based on device capabilities to eliminate unnecessary color conversions - options.destinationDynamicRange = getOptimalDynamicRange() + // HDR handling: Let KSPlayer automatically detect content's native dynamic range + // Setting destinationDynamicRange to nil allows KSPlayer to use the content's actual HDR/SDR mode + // This prevents forcing HDR tone mapping on SDR content (which causes oversaturation) + // KSPlayer will automatically detect HDR10/Dolby Vision/HLG from the video format description + options.destinationDynamicRange = nil // Configure audio for proper dialogue mixing using FFmpeg's pan filter // This approach uses standard audio engineering practices for multi-channel downmixing @@ -804,40 +806,6 @@ class KSPlayerView: UIView { } // MARK: - Performance Optimization Helpers - - /// Detects device HDR capabilities and returns optimal dynamic range setting - /// This prevents unnecessary color space conversion overhead - private func getOptimalDynamicRange() -> DynamicRange? { - #if canImport(UIKit) - let availableHDRModes = AVPlayer.availableHDRModes - - // If no HDR modes available, use SDR (nil will use content's native range) - if availableHDRModes == AVPlayer.HDRMode(rawValue: 0) { - return .sdr - } - - // Prefer HDR10 if supported (most common HDR format) - if availableHDRModes.contains(.hdr10) { - return .hdr10 - } - - // Fallback to Dolby Vision if available - if availableHDRModes.contains(.dolbyVision) { - return .dolbyVision - } - - // Fallback to HLG if available - if availableHDRModes.contains(.hlg) { - return .hlg - } - - // Default to SDR if no HDR support - return .sdr - #else - // macOS: Check screen capabilities - return .sdr - #endif - } } // MARK: - High Performance KSOptions Subclass diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 7aa251a8..ef6aeb9e 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -89,7 +89,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe // Subscribe to library updates and update local state if this item's status changes const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => { const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type); - setInLibrary(!!found); + const newInLibrary = !!found; + // Only update state if the value actually changed to prevent unnecessary re-renders + setInLibrary(prev => prev !== newInLibrary ? newInLibrary : prev); }); return () => unsubscribe(); }, [item.id, item.type]); diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 2a9dd866..e720f745 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -1041,45 +1041,49 @@ const HeroSection: React.FC = memo(({ // Grace delay before showing text fallback to avoid flashing when logo arrives late const [shouldShowTextFallback, setShouldShowTextFallback] = useState(!metadata?.logo); const logoWaitTimerRef = useRef(null); + // Ref to track the last synced logo to break circular dependency with error handling + const lastSyncedLogoRef = useRef(metadata?.logo); // Update stable logo URI when metadata logo changes useEffect(() => { - // Reset text fallback and timers on logo updates - if (logoWaitTimerRef.current) { - try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {} - logoWaitTimerRef.current = null; - } + // Check if metadata logo has actually changed from what we last processed + const currentMetadataLogo = metadata?.logo; + + if (currentMetadataLogo !== lastSyncedLogoRef.current) { + lastSyncedLogoRef.current = currentMetadataLogo; - if (metadata?.logo && metadata.logo !== stableLogoUri) { - setStableLogoUri(metadata.logo); - onStableLogoUriChange?.(metadata.logo); - setLogoHasLoadedSuccessfully(false); // Reset for new logo - logoLoadOpacity.value = 0; // reset fade for new logo - setShouldShowTextFallback(false); - } else if (!metadata?.logo && stableLogoUri) { - // Clear logo if metadata no longer has one - setStableLogoUri(null); - onStableLogoUriChange?.(null); - setLogoHasLoadedSuccessfully(false); - // Start a short grace period before showing text fallback - setShouldShowTextFallback(false); - logoWaitTimerRef.current = setTimeout(() => { - setShouldShowTextFallback(true); - }, 600); - } else if (!metadata?.logo && !stableLogoUri) { - // No logo currently; wait briefly before showing text to avoid flash - setShouldShowTextFallback(false); - logoWaitTimerRef.current = setTimeout(() => { - setShouldShowTextFallback(true); - }, 600); + // Reset text fallback and timers on logo updates + if (logoWaitTimerRef.current) { + try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {} + logoWaitTimerRef.current = null; + } + + if (currentMetadataLogo) { + setStableLogoUri(currentMetadataLogo); + onStableLogoUriChange?.(currentMetadataLogo); + setLogoHasLoadedSuccessfully(false); // Reset for new logo + logoLoadOpacity.value = 0; // reset fade for new logo + setShouldShowTextFallback(false); + } else { + // Clear logo if metadata no longer has one + setStableLogoUri(null); + onStableLogoUriChange?.(null); + setLogoHasLoadedSuccessfully(false); + // Start a short grace period before showing text fallback + setShouldShowTextFallback(false); + logoWaitTimerRef.current = setTimeout(() => { + setShouldShowTextFallback(true); + }, 600); + } } + return () => { if (logoWaitTimerRef.current) { try { clearTimeout(logoWaitTimerRef.current); } catch (_e) {} logoWaitTimerRef.current = null; } }; - }, [metadata?.logo, stableLogoUri]); + }, [metadata?.logo]); // Removed stableLogoUri from dependencies to prevent circular updates on error // Handle logo load success - once loaded successfully, keep it stable const handleLogoLoad = useCallback(() => { diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 1279b586..d4f9e48f 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -2168,7 +2168,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat useEffect(() => { const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => { const isInLib = libraryItems.some(item => item.id === id); - setInLibrary(isInLib); + // Only update state if the value actually changed to prevent unnecessary re-renders + setInLibrary(prev => prev !== isInLib ? isInLib : prev); }); return () => unsubscribe(); diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index b6970b22..a174e94b 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -928,8 +928,16 @@ class CatalogService { public subscribeToLibraryUpdates(callback: (items: StreamingContent[]) => void): () => void { this.librarySubscribers.push(callback); - // Initial callback with current items - this.getLibraryItems().then(items => callback(items)); + // Defer initial callback to next tick to avoid synchronous state updates during render + // This prevents infinite loops when the callback triggers setState in useEffect + Promise.resolve().then(() => { + this.getLibraryItems().then(items => { + // Only call if still subscribed (callback might have been unsubscribed) + if (this.librarySubscribers.includes(callback)) { + callback(items); + } + }); + }); // Return unsubscribe function return () => { From 56234daf82adac10cc05420e2779033e8c7f757b Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 24 Nov 2025 23:25:03 +0530 Subject: [PATCH 02/15] gradle fix --- android/app/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/app/build.gradle b/android/app/build.gradle index 18b84866..0d17fb41 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -209,6 +209,10 @@ sentry { } } +configurations.all { + exclude group: 'com.caverock', module: 'androidsvg' +} + dependencies { // @generated begin react-native-google-cast-dependencies - expo prebuild (DO NOT MODIFY) sync-3822a3c86222e7aca74039b551612aab7e75365d implementation "com.google.android.gms:play-services-cast-framework:${safeExtGet('castFrameworkVersion', '+')}" From a27ee4ac56905aa1be3572532f0e560375370b64 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 25 Nov 2025 00:50:47 +0530 Subject: [PATCH 03/15] trakt test --- .../home/ContinueWatchingSection.tsx | 339 ++++++++++++------ src/services/traktService.ts | 164 +++++---- 2 files changed, 311 insertions(+), 192 deletions(-) diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 436409c1..9d062c29 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -1,9 +1,9 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, +import { + View, + Text, + StyleSheet, + TouchableOpacity, Dimensions, AppState, AppStateStatus, @@ -55,17 +55,17 @@ const calculatePosterLayout = (screenWidth: number) => { const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items const MAX_POSTER_WIDTH = 160; // Maximum poster width for this section const HORIZONTAL_PADDING = 40; // Total horizontal padding/margins - + // Calculate how many posters can fit (fewer items for continue watching) const availableWidth = screenWidth - HORIZONTAL_PADDING; const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH); - + // Limit to reasonable number of columns (2-5 for continue watching) const numColumns = Math.min(Math.max(maxColumns, 2), 5); - + // Calculate actual poster width const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH); - + return { numColumns, posterWidth, @@ -85,7 +85,7 @@ const isSupportedId = (id: string): boolean => { // Function to check if an episode has been released const isEpisodeReleased = (video: any): boolean => { if (!video.released) return false; - + try { const releaseDate = new Date(video.released); const now = new Date(); @@ -112,16 +112,16 @@ const ContinueWatchingSection = React.forwardRef((props, re const [dimensions, setDimensions] = useState(Dimensions.get('window')); const deviceWidth = dimensions.width; const deviceHeight = dimensions.height; - + // Listen for dimension changes (orientation changes) useEffect(() => { const subscription = Dimensions.addEventListener('change', ({ window }) => { setDimensions(window); }); - + return () => subscription?.remove(); }, []); - + // Determine device type based on width const getDeviceType = useCallback(() => { if (deviceWidth >= BREAKPOINTS.tv) return 'tv'; @@ -129,13 +129,13 @@ const ContinueWatchingSection = React.forwardRef((props, re if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet'; return 'phone'; }, [deviceWidth]); - + const deviceType = getDeviceType(); const isTablet = deviceType === 'tablet'; const isLargeTablet = deviceType === 'largeTablet'; const isTV = deviceType === 'tv'; const isLargeScreen = isTablet || isLargeTablet || isTV; - + // Enhanced responsive sizing for continue watching items const computedItemWidth = useMemo(() => { switch (deviceType) { @@ -149,7 +149,7 @@ const ContinueWatchingSection = React.forwardRef((props, re return 280; // Original phone size } }, [deviceType]); - + const computedItemHeight = useMemo(() => { switch (deviceType) { case 'tv': @@ -162,7 +162,7 @@ const ContinueWatchingSection = React.forwardRef((props, re return 120; // Original phone height } }, [deviceType]); - + // Enhanced spacing and padding const horizontalPadding = useMemo(() => { switch (deviceType) { @@ -176,7 +176,7 @@ const ContinueWatchingSection = React.forwardRef((props, re return 16; // phone } }, [deviceType]); - + const itemSpacing = useMemo(() => { switch (deviceType) { case 'tv': @@ -198,11 +198,11 @@ const ContinueWatchingSection = React.forwardRef((props, re // Use a ref to track if a background refresh is in progress to avoid state updates const isRefreshingRef = useRef(false); - + // Track recently removed items to prevent immediate re-addition const recentlyRemovedRef = useRef>(new Set()); const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds - + // Track last Trakt sync to prevent excessive API calls const lastTraktSyncRef = useRef(0); const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs @@ -216,18 +216,18 @@ const ContinueWatchingSection = React.forwardRef((props, re const cacheKey = `${type}:${id}`; const cached = metadataCache.current[cacheKey]; const now = Date.now(); - + if (cached && (now - cached.timestamp) < CACHE_DURATION) { return cached; } - + try { const shouldFetchMeta = await stremioService.isValidContentId(type, id); const [metadata, basicContent] = await Promise.all([ shouldFetchMeta ? stremioService.getMetaDetails(type, id) : Promise.resolve(null), catalogService.getBasicContentDetails(type, id) ]); - + if (basicContent) { const result = { metadata, basicContent, timestamp: now }; metadataCache.current[cacheKey] = result; @@ -334,13 +334,67 @@ const ContinueWatchingSection = React.forwardRef((props, re 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)) { - const ids = watched - .map((w: any) => w?.movie?.ids?.imdb) - .filter(Boolean) - .map((imdb: string) => (imdb.startsWith('tt') ? imdb : `tt${imdb}`)); - return new Set(ids); + 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(); + } + })(); + + // 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 { @@ -365,7 +419,7 @@ const ContinueWatchingSection = React.forwardRef((props, re traktSynced: true, traktProgress: 100, } as any); - } catch (_e) {} + } catch (_e) { } return; } } @@ -422,6 +476,8 @@ const ContinueWatchingSection = React.forwardRef((props, re 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) { @@ -442,6 +498,61 @@ const ContinueWatchingSection = React.forwardRef((props, re } } } + + // 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) { + let nextSeason = season; + let nextEpisode = (episodeNumber || 0) + 1; + + if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) { + const nextEpisodeVideo = metadata.videos.find((video: any) => + video.season === nextSeason && video.episode === nextEpisode + ); + if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { + batch.push({ + ...basicContent, + id: group.id, + type: group.type, + progress: 0, + lastUpdated: progress.lastUpdated, + season: nextSeason, + episode: nextEpisode, + episodeTitle: `Episode ${nextEpisode}`, + } as ContinueWatchingItem); + } + } + continue; } batch.push({ @@ -466,14 +577,14 @@ const ContinueWatchingSection = React.forwardRef((props, re const traktService = TraktService.getInstance(); const isAuthed = await traktService.isAuthenticated(); if (!isAuthed) return; - + // Check Trakt sync cooldown to prevent excessive API calls const now = Date.now(); if (now - lastTraktSyncRef.current < TRAKT_SYNC_COOLDOWN) { logger.log(`[TraktSync] Skipping Trakt sync - cooldown active (${Math.round((TRAKT_SYNC_COOLDOWN - (now - lastTraktSyncRef.current)) / 1000)}s remaining)`); return; } - + lastTraktSyncRef.current = now; const historyItems = await traktService.getWatchedEpisodesHistory(1, 200); const latestWatchedByShow: Record = {}; @@ -650,7 +761,7 @@ const ContinueWatchingSection = React.forwardRef((props, re useFocusEffect( useCallback(() => { loadContinueWatching(true); - return () => {}; + return () => { }; }, [loadContinueWatching]) ); @@ -667,62 +778,62 @@ const ContinueWatchingSection = React.forwardRef((props, re const handleContentPress = useCallback(async (item: ContinueWatchingItem) => { try { logger.log(`🎬 [ContinueWatching] User clicked on: ${item.name} (${item.type}:${item.id})`); - + // Check if cached streams are enabled in settings if (!settings.useCachedStreams) { logger.log(`📺 [ContinueWatching] Cached streams disabled, navigating to ${settings.openMetadataScreenWhenCacheDisabled ? 'MetadataScreen' : 'StreamsScreen'} for ${item.name}`); - + // Navigate based on the second setting if (settings.openMetadataScreenWhenCacheDisabled) { // Navigate to MetadataScreen if (item.type === 'series' && item.season && item.episode) { const episodeId = `${item.id}:${item.season}:${item.episode}`; - navigation.navigate('Metadata', { - id: item.id, - type: item.type, - episodeId: episodeId + navigation.navigate('Metadata', { + id: item.id, + type: item.type, + episodeId: episodeId }); } else { - navigation.navigate('Metadata', { - id: item.id, - type: item.type + navigation.navigate('Metadata', { + id: item.id, + type: item.type }); } } else { // Navigate to StreamsScreen if (item.type === 'series' && item.season && item.episode) { const episodeId = `${item.id}:${item.season}:${item.episode}`; - navigation.navigate('Streams', { - id: item.id, - type: item.type, - episodeId: episodeId + navigation.navigate('Streams', { + id: item.id, + type: item.type, + episodeId: episodeId }); } else { - navigation.navigate('Streams', { - id: item.id, - type: item.type + navigation.navigate('Streams', { + id: item.id, + type: item.type }); } } return; } - + // Check if we have a cached stream for this content - const episodeId = item.type === 'series' && item.season && item.episode - ? `${item.id}:${item.season}:${item.episode}` + const episodeId = item.type === 'series' && item.season && item.episode + ? `${item.id}:${item.season}:${item.episode}` : undefined; - + logger.log(`🔍 [ContinueWatching] Looking for cached stream with episodeId: ${episodeId || 'none'}`); - + const cachedStream = await streamCacheService.getCachedStream(item.id, item.type, episodeId); - + if (cachedStream) { // We have a valid cached stream, navigate directly to player logger.log(`🚀 [ContinueWatching] Using cached stream for ${item.name}`); - + // Determine the player route based on platform const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; - + // Navigate directly to player with cached stream data navigation.navigate(playerRoute as any, { uri: cachedStream.stream.url, @@ -743,25 +854,25 @@ const ContinueWatchingSection = React.forwardRef((props, re backdrop: cachedStream.metadata?.backdrop || item.banner, videoType: undefined, // Let player auto-detect } as any); - + return; } - + // No cached stream or cache failed, navigate to StreamsScreen logger.log(`📺 [ContinueWatching] No cached stream, navigating to StreamsScreen for ${item.name}`); - + if (item.type === 'series' && item.season && item.episode) { // For series, navigate to the specific episode - navigation.navigate('Streams', { - id: item.id, - type: item.type, - episodeId: episodeId + navigation.navigate('Streams', { + id: item.id, + type: item.type, + episodeId: episodeId }); } else { // For movies or series without specific episode, navigate to main content - navigation.navigate('Streams', { - id: item.id, - type: item.type + navigation.navigate('Streams', { + id: item.id, + type: item.type }); } } catch (error) { @@ -769,15 +880,15 @@ const ContinueWatchingSection = React.forwardRef((props, re // Fallback to StreamsScreen on any error if (item.type === 'series' && item.season && item.episode) { const episodeId = `${item.id}:${item.season}:${item.episode}`; - navigation.navigate('Streams', { - id: item.id, - type: item.type, - episodeId: episodeId + navigation.navigate('Streams', { + id: item.id, + type: item.type, + episodeId: episodeId }); } else { - navigation.navigate('Streams', { - id: item.id, - type: item.type + navigation.navigate('Streams', { + id: item.id, + type: item.type }); } } @@ -798,7 +909,7 @@ const ContinueWatchingSection = React.forwardRef((props, re { label: 'Cancel', style: { color: '#888' }, - onPress: () => {}, + onPress: () => { }, }, { label: 'Remove', @@ -842,7 +953,7 @@ const ContinueWatchingSection = React.forwardRef((props, re const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( ((props, re } ]}> ((props, re style={styles.continueWatchingPoster} resizeMode={FastImage.resizeMode.cover} /> - + {/* Delete Indicator Overlay */} {deletingItemId === item.id && ( @@ -893,10 +1004,10 @@ const ContinueWatchingSection = React.forwardRef((props, re const isUpNext = item.type === 'series' && item.progress === 0; return ( - ((props, re {item.name} {isUpNext && ( - + Up Next - + )} ); @@ -931,8 +1042,8 @@ const ContinueWatchingSection = React.forwardRef((props, re return ( ((props, re Season {item.season} {item.episodeTitle && ( - ((props, re } else { return ( ((props, re height: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4 } ]}> - ((props, re Continue Watching ((props, re ]} /> - + ((props, re showsHorizontalScrollIndicator={false} contentContainerStyle={[ styles.wideList, - { - paddingLeft: horizontalPadding, - paddingRight: horizontalPadding + { + paddingLeft: horizontalPadding, + paddingRight: horizontalPadding } ]} ItemSeparatorComponent={ItemSeparator} onEndReachedThreshold={0.7} - onEndReached={() => {}} + onEndReached={() => { }} removeClippedSubviews={true} /> @@ -1209,7 +1320,7 @@ const styles = StyleSheet.create({ }, contentItem: { width: POSTER_WIDTH, - aspectRatio: 2/3, + aspectRatio: 2 / 3, margin: 0, borderRadius: 8, overflow: 'hidden', diff --git a/src/services/traktService.ts b/src/services/traktService.ts index d654855c..bd79d7a3 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -52,6 +52,14 @@ export interface TraktWatchedItem { }; plays: number; last_watched_at: string; + seasons?: { + number: number; + episodes: { + number: number; + plays: number; + last_watched_at: string; + }[]; + }[]; } export interface TraktWatchlistItem { @@ -559,7 +567,7 @@ export class TraktService { private refreshToken: string | null = null; private tokenExpiry: number = 0; private isInitialized: boolean = false; - + // Rate limiting - Optimized for real-time scrobbling private lastApiCall: number = 0; private readonly MIN_API_INTERVAL = 500; // Reduced to 500ms for faster updates @@ -575,21 +583,21 @@ export class TraktService { private currentlyWatching: Set = new Set(); private lastSyncTimes: Map = new Map(); private readonly SYNC_DEBOUNCE_MS = 5000; // Reduced from 20000ms to 5000ms for real-time updates - + // Debounce for stop calls - Optimized for responsiveness private lastStopCalls: Map = new Map(); private readonly STOP_DEBOUNCE_MS = 1000; // Reduced from 3000ms to 1000ms for better responsiveness - + // Default completion threshold (overridden by user settings) private readonly DEFAULT_COMPLETION_THRESHOLD = 80; // 80% private constructor() { // Increased cleanup interval from 5 minutes to 15 minutes to reduce heating setInterval(() => this.cleanupOldStopCalls(), 15 * 60 * 1000); // Clean up every 15 minutes - + // Add AppState cleanup to reduce memory pressure AppState.addEventListener('change', this.handleAppStateChange); - + // Load user settings this.loadCompletionThreshold(); } @@ -611,21 +619,21 @@ export class TraktService { logger.error('[TraktService] Error loading completion threshold:', error); } } - + /** * Get the current completion threshold (user-configured or default) */ private get completionThreshold(): number { return this._completionThreshold || this.DEFAULT_COMPLETION_THRESHOLD; } - + /** * Set the completion threshold */ private set completionThreshold(value: number) { this._completionThreshold = value; } - + // Backing field for completion threshold private _completionThreshold: number | null = null; @@ -635,7 +643,7 @@ export class TraktService { private cleanupOldStopCalls(): void { const now = Date.now(); let cleanupCount = 0; - + // Remove stop calls older than the debounce window for (const [key, timestamp] of this.lastStopCalls.entries()) { if (now - timestamp > this.STOP_DEBOUNCE_MS) { @@ -643,7 +651,7 @@ export class TraktService { cleanupCount++; } } - + // Also clean up old scrobbled timestamps for (const [key, timestamp] of this.scrobbledTimestamps.entries()) { if (now - timestamp > this.SCROBBLE_EXPIRY_MS) { @@ -652,7 +660,7 @@ export class TraktService { cleanupCount++; } } - + // Clean up old sync times that haven't been updated in a while for (const [key, timestamp] of this.lastSyncTimes.entries()) { if (now - timestamp > 24 * 60 * 60 * 1000) { // 24 hours @@ -660,7 +668,7 @@ export class TraktService { cleanupCount++; } } - + // Skip verbose cleanup logging to reduce CPU load } @@ -703,7 +711,7 @@ export class TraktService { */ public async isAuthenticated(): Promise { await this.ensureInitialized(); - + if (!this.accessToken) { return false; } @@ -908,12 +916,12 @@ export class TraktService { const maxRetries = 3; if (retryCount < maxRetries) { const retryAfter = response.headers.get('Retry-After'); - const delay = retryAfter - ? parseInt(retryAfter) * 1000 + const delay = retryAfter + ? parseInt(retryAfter) * 1000 : Math.min(1000 * Math.pow(2, retryCount), 10000); // Exponential backoff, max 10s - + logger.log(`[TraktService] Rate limited (429), retrying in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`); - + await new Promise(resolve => setTimeout(resolve, delay)); return this.apiRequest(endpoint, method, body, retryCount + 1); } else { @@ -926,13 +934,13 @@ export class TraktService { if (response.status === 409) { const errorText = await response.text(); logger.log(`[TraktService] Content already scrobbled (409) for ${endpoint}:`, errorText); - + // Parse the error response to get expiry info try { const errorData = JSON.parse(errorText); if (errorData.watched_at && errorData.expires_at) { logger.log(`[TraktService] Item was already watched at ${errorData.watched_at}, expires at ${errorData.expires_at}`); - + // If this is a scrobble endpoint, mark the item as already scrobbled if (endpoint.includes('/scrobble/') && body) { const contentKey = this.getContentKeyFromPayload(body); @@ -942,7 +950,7 @@ export class TraktService { logger.log(`[TraktService] Marked content as already scrobbled: ${contentKey}`); } } - + // Return a success-like response for 409 conflicts // This prevents the error from bubbling up and causing retry loops return { @@ -955,7 +963,7 @@ export class TraktService { } catch (parseError) { logger.warn(`[TraktService] Could not parse 409 error response: ${parseError}`); } - + // Return a graceful response even if we can't parse the error return { id: 0, @@ -967,7 +975,7 @@ export class TraktService { if (!response.ok) { const errorText = await response.text(); - + // Enhanced error logging for debugging logger.error(`[TraktService] API Error ${response.status} for ${endpoint}:`, { status: response.status, @@ -976,14 +984,14 @@ export class TraktService { requestBody: body ? JSON.stringify(body, null, 2) : 'No body', headers: Object.fromEntries(response.headers.entries()) }); - + // Handle 404 errors more gracefully - they might indicate content not found in Trakt if (response.status === 404) { logger.warn(`[TraktService] Content not found in Trakt database (404) for ${endpoint}. This might indicate:`); logger.warn(`[TraktService] 1. Invalid IMDb ID: ${body?.movie?.ids?.imdb || body?.show?.ids?.imdb || 'N/A'}`); logger.warn(`[TraktService] 2. Content not in Trakt database: ${body?.movie?.title || body?.show?.title || 'N/A'}`); logger.warn(`[TraktService] 3. Authentication issues with token`); - + // Return a graceful response for 404s instead of throwing return { id: 0, @@ -992,7 +1000,7 @@ export class TraktService { error: 'Content not found in Trakt database' } as any; } - + throw new Error(`API request failed: ${response.status}`); } @@ -1016,7 +1024,7 @@ export class TraktService { if (endpoint.includes('/scrobble/')) { // API success logging removed } - + return responseData; } @@ -1041,7 +1049,7 @@ export class TraktService { */ private isRecentlyScrobbled(contentData: TraktContentData): boolean { const contentKey = this.getWatchingKey(contentData); - + // Clean up expired entries const now = Date.now(); for (const [key, timestamp] of this.scrobbledTimestamps.entries()) { @@ -1050,7 +1058,7 @@ export class TraktService { this.scrobbledTimestamps.delete(key); } } - + return this.scrobbledItems.has(contentKey); } @@ -1181,7 +1189,7 @@ export class TraktService { if (!images || !images.poster || images.poster.length === 0) { return null; } - + // Get the first poster and add https prefix const posterPath = images.poster[0]; return posterPath.startsWith('http') ? posterPath : `https://${posterPath}`; @@ -1194,7 +1202,7 @@ export class TraktService { if (!images || !images.fanart || images.fanart.length === 0) { return null; } - + // Get the first fanart and add https prefix const fanartPath = images.fanart[0]; return fanartPath.startsWith('http') ? fanartPath : `https://${fanartPath}`; @@ -1291,9 +1299,9 @@ export class TraktService { * Add a show episode to user's watched history */ public async addToWatchedEpisodes( - imdbId: string, - season: number, - episode: number, + imdbId: string, + season: number, + episode: number, watchedAt: Date = new Date() ): Promise { try { @@ -1355,8 +1363,8 @@ export class TraktService { * Check if a show episode is in user's watched history */ public async isEpisodeWatched( - imdbId: string, - season: number, + imdbId: string, + season: number, episode: number ): Promise { try { @@ -1478,19 +1486,19 @@ export class TraktService { */ private validateContentData(contentData: TraktContentData): { isValid: boolean; errors: string[] } { const errors: string[] = []; - + if (!contentData.type || !['movie', 'episode'].includes(contentData.type)) { errors.push('Invalid content type'); } - + if (!contentData.title || contentData.title.trim() === '') { errors.push('Missing or empty title'); } - + if (!contentData.imdbId || contentData.imdbId.trim() === '') { errors.push('Missing or empty IMDb ID'); } - + if (contentData.type === 'episode') { if (!contentData.season || contentData.season < 1) { errors.push('Invalid season number'); @@ -1505,7 +1513,7 @@ export class TraktService { errors.push('Invalid show year'); } } - + return { isValid: errors.length === 0, errors @@ -1547,7 +1555,7 @@ export class TraktService { const imdbIdWithPrefix = contentData.imdbId.startsWith('tt') ? contentData.imdbId : `tt${contentData.imdbId}`; - + const payload = { movie: { title: contentData.title, @@ -1558,7 +1566,7 @@ export class TraktService { }, progress: clampedProgress }; - + logger.log('[TraktService] Movie payload built:', payload); return payload; } else if (contentData.type === 'episode') { @@ -1598,11 +1606,11 @@ export class TraktService { const episodeImdbWithPrefix = contentData.imdbId.startsWith('tt') ? contentData.imdbId : `tt${contentData.imdbId}`; - + if (!payload.episode.ids) { payload.episode.ids = {}; } - + payload.episode.ids.imdb = episodeImdbWithPrefix; } @@ -1635,7 +1643,7 @@ export class TraktService { } catch (error) { logger.error('[TraktService] Queue request failed:', error); } - + // Wait minimum interval before next request if (this.requestQueue.length > 0) { await new Promise(resolve => setTimeout(resolve, this.MIN_API_INTERVAL)); @@ -1659,7 +1667,7 @@ export class TraktService { reject(error); } }); - + // Start processing if not already running this.processQueue(); }); @@ -1702,7 +1710,7 @@ export class TraktService { } // Debug log removed to reduce terminal noise - + // Only start if not already watching this content if (this.currentlyWatching.has(watchingKey)) { logger.log(`[TraktService] Already watching this content, skipping start: ${contentData.title}`); @@ -1736,10 +1744,10 @@ export class TraktService { } const now = Date.now(); - + const watchingKey = this.getWatchingKey(contentData); const lastSync = this.lastSyncTimes.get(watchingKey) || 0; - + // IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 100ms) if (!force && (now - lastSync) < 100) { return true; // Skip this sync, but return success @@ -1763,7 +1771,7 @@ export class TraktService { logger.warn('[TraktService] Rate limited, will retry later'); return true; // Return success to avoid error spam } - + logger.error('[TraktService] Failed to update progress:', error); return false; } @@ -1794,7 +1802,7 @@ export class TraktService { // Use pause if below user threshold, stop only when ready to scrobble const useStop = progress >= this.completionThreshold; const result = await this.queueRequest(async () => { - return useStop + return useStop ? await this.stopWatching(contentData, progress) : await this.pauseWatching(contentData, progress); }); @@ -1923,8 +1931,8 @@ export class TraktService { * @deprecated Use scrobbleStart, scrobblePause, scrobbleStop instead */ public async syncProgressToTrakt( - contentData: TraktContentData, - progress: number, + contentData: TraktContentData, + progress: number, force: boolean = false ): Promise { // For backward compatibility, treat as a pause update @@ -1937,11 +1945,11 @@ export class TraktService { public async debugTraktConnection(): Promise { try { logger.log('[TraktService] Testing Trakt API connection...'); - + // Test basic API access const userResponse = await this.apiRequest('/users/me', 'GET'); logger.log('[TraktService] User info:', userResponse); - + // Test a minimal scrobble start to verify API works const testPayload = { movie: { @@ -1953,19 +1961,19 @@ export class TraktService { }, progress: 1.0 }; - + logger.log('[TraktService] Testing scrobble/start endpoint with test payload...'); const scrobbleResponse = await this.apiRequest('/scrobble/start', 'POST', testPayload); logger.log('[TraktService] Scrobble test response:', scrobbleResponse); - - return { + + return { authenticated: true, - user: userResponse, - scrobbleTest: scrobbleResponse + user: userResponse, + scrobbleTest: scrobbleResponse }; } catch (error) { logger.error('[TraktService] Debug connection failed:', error); - return { + return { authenticated: false, error: error instanceof Error ? error.message : String(error) }; @@ -1984,7 +1992,7 @@ export class TraktService { const progress = await this.getPlaybackProgress(); // Progress logging removed - + progress.forEach((item, index) => { if (item.type === 'movie' && item.movie) { // Movie progress logging removed @@ -1992,7 +2000,7 @@ export class TraktService { // Episode progress logging removed } }); - + if (progress.length === 0) { // No progress logging removed } @@ -2022,16 +2030,16 @@ export class TraktService { public async deletePlaybackForContent(imdbId: string, type: 'movie' | 'series', season?: number, episode?: number): Promise { try { logger.log(`🔍 [TraktService] deletePlaybackForContent called for ${type}:${imdbId} (season:${season}, episode:${episode})`); - + if (!this.accessToken) { logger.log(`❌ [TraktService] No access token - cannot delete playback`); return false; } - + logger.log(`🔍 [TraktService] Fetching current playback progress...`); const progressItems = await this.getPlaybackProgress(); logger.log(`📊 [TraktService] Found ${progressItems.length} playback items`); - + const target = progressItems.find(item => { if (type === 'movie' && item.type === 'movie' && item.movie?.ids.imdb === imdbId) { logger.log(`🎯 [TraktService] Found matching movie: ${item.movie?.title}`); @@ -2050,7 +2058,7 @@ export class TraktService { } return false; }); - + if (target) { logger.log(`🗑️ [TraktService] Deleting playback item with ID: ${target.id}`); const result = await this.deletePlaybackItem(target.id); @@ -2475,7 +2483,7 @@ export class TraktService { // Ensure IMDb ID includes the 'tt' prefix const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; - const payload = type === 'movie' + const payload = type === 'movie' ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; @@ -2500,7 +2508,7 @@ export class TraktService { // Ensure IMDb ID includes the 'tt' prefix const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; - const payload = type === 'movie' + const payload = type === 'movie' ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; @@ -2525,7 +2533,7 @@ export class TraktService { // Ensure IMDb ID includes the 'tt' prefix const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; - const payload = type === 'movie' + const payload = type === 'movie' ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; @@ -2550,7 +2558,7 @@ export class TraktService { // Ensure IMDb ID includes the 'tt' prefix const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; - const payload = type === 'movie' + const payload = type === 'movie' ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; @@ -2575,13 +2583,13 @@ export class TraktService { // Ensure IMDb ID includes the 'tt' prefix const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; - const watchlistItems = type === 'movie' + const watchlistItems = type === 'movie' ? await this.getWatchlistMovies() : await this.getWatchlistShows(); return watchlistItems.some(item => { - const itemImdbId = type === 'movie' - ? item.movie?.ids?.imdb + const itemImdbId = type === 'movie' + ? item.movie?.ids?.imdb : item.show?.ids?.imdb; return itemImdbId === imdbIdWithPrefix; }); @@ -2603,13 +2611,13 @@ export class TraktService { // Ensure IMDb ID includes the 'tt' prefix const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; - const collectionItems = type === 'movie' + const collectionItems = type === 'movie' ? await this.getCollectionMovies() : await this.getCollectionShows(); return collectionItems.some(item => { - const itemImdbId = type === 'movie' - ? item.movie?.ids?.imdb + const itemImdbId = type === 'movie' + ? item.movie?.ids?.imdb : item.show?.ids?.imdb; return itemImdbId === imdbIdWithPrefix; }); @@ -2630,7 +2638,7 @@ export class TraktService { this.currentlyWatching.clear(); this.lastSyncTimes.clear(); this.lastStopCalls.clear(); - + // Clear request queue to prevent background processing this.requestQueue = []; this.isProcessingQueue = false; From 0ab85ec8707ededbe60708c25f9753a6f86244c3 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 25 Nov 2025 00:56:19 +0530 Subject: [PATCH 04/15] thisweekimprovements --- src/hooks/useCalendarData.ts | 815 ++++++++++++++++++----------------- temp_libtorrent | 1 + 2 files changed, 417 insertions(+), 399 deletions(-) create mode 160000 temp_libtorrent diff --git a/src/hooks/useCalendarData.ts b/src/hooks/useCalendarData.ts index 888761c8..861a6707 100644 --- a/src/hooks/useCalendarData.ts +++ b/src/hooks/useCalendarData.ts @@ -10,24 +10,24 @@ import { parseISO, isBefore, isAfter, startOfToday, addWeeks, isThisWeek } from import { StreamingContent } from '../services/catalogService'; interface CalendarEpisode { - id: string; - seriesId: string; - title: string; - seriesName: string; - poster: string; - releaseDate: string; - season: number; - episode: number; - overview: string; - vote_average: number; - still_path: string | null; - season_poster_path: string | null; - } - - interface CalendarSection { - title: string; - data: CalendarEpisode[]; - } + id: string; + seriesId: string; + title: string; + seriesName: string; + poster: string; + releaseDate: string; + season: number; + episode: number; + overview: string; + vote_average: number; + still_path: string | null; + season_poster_path: string | null; +} + +interface CalendarSection { + title: string; + data: CalendarEpisode[]; +} interface UseCalendarDataReturn { calendarData: CalendarSection[]; @@ -36,399 +36,416 @@ interface UseCalendarDataReturn { } export const useCalendarData = (): UseCalendarDataReturn => { - const [calendarData, setCalendarData] = useState([]); - const [loading, setLoading] = useState(true); + const [calendarData, setCalendarData] = useState([]); + const [loading, setLoading] = useState(true); - const { libraryItems, loading: libraryLoading } = useLibrary(); - const { - isAuthenticated: traktAuthenticated, - isLoading: traktLoading, - watchedShows, - watchlistShows, - continueWatching, - loadAllCollections, - } = useTraktContext(); + const { libraryItems, loading: libraryLoading } = useLibrary(); + const { + isAuthenticated: traktAuthenticated, + isLoading: traktLoading, + watchedShows, + watchlistShows, + continueWatching, + loadAllCollections, + } = useTraktContext(); - const fetchCalendarData = useCallback(async (forceRefresh = false) => { - setLoading(true); - - try { - // Check memory pressure and cleanup if needed - memoryManager.checkMemoryPressure(); + const fetchCalendarData = useCallback(async (forceRefresh = false) => { + setLoading(true); - if (!forceRefresh) { - const cachedData = await robustCalendarCache.getCachedCalendarData( - libraryItems, - { - watchlist: watchlistShows, - continueWatching: continueWatching, - watched: watchedShows, - } - ); + try { + // Check memory pressure and cleanup if needed + memoryManager.checkMemoryPressure(); - if (cachedData) { - setCalendarData(cachedData); - setLoading(false); - return; - } + if (!forceRefresh) { + const cachedData = await robustCalendarCache.getCachedCalendarData( + libraryItems, + { + watchlist: watchlistShows, + continueWatching: continueWatching, + watched: watchedShows, } - - - const librarySeries = libraryItems.filter(item => item.type === 'series'); - let allSeries: StreamingContent[] = [...librarySeries]; - - if (traktAuthenticated) { - const traktSeriesIds = new Set(); - - if (watchlistShows) { - for (const item of watchlistShows) { - if (item.show && item.show.ids.imdb) { - const imdbId = item.show.ids.imdb; - if (!librarySeries.some(s => s.id === imdbId)) { - traktSeriesIds.add(imdbId); - allSeries.push({ - id: imdbId, - name: item.show.title, - type: 'series', - poster: '', - year: item.show.year, - traktSource: 'watchlist' - }); - } - } - } - } - - if (continueWatching) { - for (const item of continueWatching) { - if (item.type === 'episode' && item.show && item.show.ids.imdb) { - const imdbId = item.show.ids.imdb; - if (!librarySeries.some(s => s.id === imdbId) && !traktSeriesIds.has(imdbId)) { - traktSeriesIds.add(imdbId); - allSeries.push({ - id: imdbId, - name: item.show.title, - type: 'series', - poster: '', - year: item.show.year, - traktSource: 'continue-watching' - }); - } - } - } - } - - if (watchedShows) { - const recentWatched = watchedShows.slice(0, 20); - for (const item of recentWatched) { - if (item.show && item.show.ids.imdb) { - const imdbId = item.show.ids.imdb; - if (!librarySeries.some(s => s.id === imdbId) && !traktSeriesIds.has(imdbId)) { - traktSeriesIds.add(imdbId); - allSeries.push({ - id: imdbId, - name: item.show.title, - type: 'series', - poster: '', - year: item.show.year, - traktSource: 'watched' - }); - } - } - } - } - } - - // Limit the number of series to prevent memory overflow - const maxSeries = 100; // Reasonable limit to prevent OOM - if (allSeries.length > maxSeries) { - logger.warn(`[CalendarData] Too many series (${allSeries.length}), limiting to ${maxSeries} to prevent memory issues`); - allSeries = allSeries.slice(0, maxSeries); - } - - logger.log(`[CalendarData] Total series to check: ${allSeries.length} (Library: ${librarySeries.length}, Trakt: ${allSeries.length - librarySeries.length})`); - - let allEpisodes: CalendarEpisode[] = []; - let seriesWithoutEpisodes: CalendarEpisode[] = []; - - // Process series in memory-efficient batches to prevent OOM - const processedSeries = await memoryManager.processArrayInBatches( - allSeries, - async (series: StreamingContent, index: number) => { - try { - // Use the new memory-efficient method to fetch upcoming and recent episodes - const episodeData = await stremioService.getUpcomingEpisodes(series.type, series.id, { - daysBack: 90, // 3 months back for recently released episodes - daysAhead: 60, // 2 months ahead for upcoming episodes - maxEpisodes: 50, // Increased limit to get more episodes per series - }); - - if (episodeData && episodeData.episodes.length > 0) { - const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id); - let tmdbEpisodes: { [key: string]: any } = {}; - - // Only fetch TMDB data if we need it and limit it - if (tmdbId && episodeData.episodes.length > 0) { - try { - // Get only current and next season to limit memory usage - const seasons = [...new Set(episodeData.episodes.map(ep => ep.season || 1))]; - const limitedSeasons = seasons.slice(0, 3); // Limit to 3 seasons max - - for (const seasonNum of limitedSeasons) { - const seasonEpisodes = await tmdbService.getSeasonDetails(tmdbId, seasonNum); - if (seasonEpisodes?.episodes) { - seasonEpisodes.episodes.forEach((episode: any) => { - const key = `${episode.season_number}:${episode.episode_number}`; - tmdbEpisodes[key] = episode; - }); - } - } - } catch (tmdbError) { - logger.warn(`[CalendarData] TMDB fetch failed for ${series.name}, continuing without additional metadata`); - } - } - - // Transform episodes with memory-efficient processing - const transformedEpisodes = episodeData.episodes.map(video => { - const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {}; - const episode = { - id: video.id, - seriesId: series.id, - title: tmdbEpisode.name || video.title || `Episode ${video.episode}`, - seriesName: series.name || episodeData.seriesName, - poster: series.poster || episodeData.poster || '', - releaseDate: video.released, - season: video.season || 0, - episode: video.episode || 0, - overview: tmdbEpisode.overview || '', - vote_average: tmdbEpisode.vote_average || 0, - still_path: tmdbEpisode.still_path || null, - season_poster_path: tmdbEpisode.season_poster_path || null - }; + ); - - return episode; - }); - - // Clear references to help garbage collection - memoryManager.clearObjects(tmdbEpisodes); - - return { type: 'episodes', data: transformedEpisodes }; - } else { - return { - type: 'no-episodes', - data: { - id: series.id, - seriesId: series.id, - title: 'No upcoming episodes', - seriesName: series.name || episodeData?.seriesName || '', - poster: series.poster || episodeData?.poster || '', - releaseDate: '', - season: 0, - episode: 0, - overview: '', - vote_average: 0, - still_path: null, - season_poster_path: null - } - }; - } - } catch (error) { - logger.error(`[CalendarData] Error fetching episodes for ${series.name}:`, error); - return { - type: 'no-episodes', - data: { - id: series.id, - seriesId: series.id, - title: 'No upcoming episodes', - seriesName: series.name || '', - poster: series.poster || '', - releaseDate: '', - season: 0, - episode: 0, - overview: '', - vote_average: 0, - still_path: null, - season_poster_path: null - } - }; - } - }, - 5, // Small batch size to prevent memory spikes - 100 // Small delay between batches - ); - - // Process results and separate episodes from no-episode series - for (const result of processedSeries) { - if (!result) { - logger.error(`[CalendarData] Null/undefined result in processedSeries`); - continue; - } - - if (result.type === 'episodes' && Array.isArray(result.data)) { - allEpisodes.push(...result.data); - } else if (result.type === 'no-episodes' && result.data) { - seriesWithoutEpisodes.push(result.data as CalendarEpisode); - } else { - logger.warn(`[CalendarData] Unexpected result type or missing data:`, result); - } - } - - // Clear processed series to free memory - memoryManager.clearObjects(processedSeries); - - // Limit total episodes to prevent memory overflow - allEpisodes = memoryManager.limitArraySize(allEpisodes, 500); - seriesWithoutEpisodes = memoryManager.limitArraySize(seriesWithoutEpisodes, 100); - - // Sort episodes by release date with error handling - allEpisodes.sort((a, b) => { - try { - const dateA = new Date(a.releaseDate).getTime(); - const dateB = new Date(b.releaseDate).getTime(); - return dateA - dateB; - } catch (error) { - logger.warn(`[CalendarData] Error sorting episodes: ${a.releaseDate} vs ${b.releaseDate}`, error); - return 0; // Keep original order if sorting fails - } - }); - - logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`); - - // Use memory-efficient filtering with error handling - const thisWeekEpisodes = await memoryManager.filterLargeArray( - allEpisodes, - ep => { - try { - if (!ep.releaseDate) return false; - const parsed = parseISO(ep.releaseDate); - return isThisWeek(parsed) && isAfter(parsed, new Date()); - } catch (error) { - logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error); - return false; - } - } - ); - - const upcomingEpisodes = await memoryManager.filterLargeArray( - allEpisodes, - ep => { - try { - if (!ep.releaseDate) return false; - const parsed = parseISO(ep.releaseDate); - return isAfter(parsed, new Date()) && !isThisWeek(parsed); - } catch (error) { - logger.warn(`[CalendarData] Error parsing date for upcoming filtering: ${ep.releaseDate}`, error); - return false; - } - } - ); - - const recentEpisodes = await memoryManager.filterLargeArray( - allEpisodes, - ep => { - try { - if (!ep.releaseDate) return false; - const parsed = parseISO(ep.releaseDate); - return isBefore(parsed, new Date()); - } catch (error) { - logger.warn(`[CalendarData] Error parsing date for recent filtering: ${ep.releaseDate}`, error); - return false; - } - } - ); - - logger.log(`[CalendarData] Episode categorization: This Week: ${thisWeekEpisodes.length}, Upcoming: ${upcomingEpisodes.length}, Recently Released: ${recentEpisodes.length}, Series without episodes: ${seriesWithoutEpisodes.length}`); - - // Debug: Show some example episodes from each category - if (thisWeekEpisodes && thisWeekEpisodes.length > 0) { - logger.log(`[CalendarData] This Week examples:`, thisWeekEpisodes.slice(0, 3).map(ep => ({ - title: ep.title, - date: ep.releaseDate, - series: ep.seriesName - }))); - } - if (recentEpisodes && recentEpisodes.length > 0) { - logger.log(`[CalendarData] Recently Released examples:`, recentEpisodes.slice(0, 3).map(ep => ({ - title: ep.title, - date: ep.releaseDate, - series: ep.seriesName - }))); - } - - const sections: CalendarSection[] = []; - if (thisWeekEpisodes.length > 0) { - sections.push({ title: 'This Week', data: thisWeekEpisodes }); - logger.log(`[CalendarData] Added 'This Week' section with ${thisWeekEpisodes.length} episodes`); - } - if (upcomingEpisodes.length > 0) { - sections.push({ title: 'Upcoming', data: upcomingEpisodes }); - logger.log(`[CalendarData] Added 'Upcoming' section with ${upcomingEpisodes.length} episodes`); - } - if (recentEpisodes.length > 0) { - sections.push({ title: 'Recently Released', data: recentEpisodes }); - logger.log(`[CalendarData] Added 'Recently Released' section with ${recentEpisodes.length} episodes`); - } - if (seriesWithoutEpisodes.length > 0) { - sections.push({ title: 'Series with No Scheduled Episodes', data: seriesWithoutEpisodes }); - logger.log(`[CalendarData] Added 'Series with No Scheduled Episodes' section with ${seriesWithoutEpisodes.length} episodes`); - } - - // Log section details before setting - logger.log(`[CalendarData] About to set calendarData with ${sections.length} sections:`); - sections.forEach((section, index) => { - logger.log(` Section ${index}: "${section.title}" with ${section.data?.length || 0} episodes`); - }); - - setCalendarData(sections); - - // Clear large arrays to help garbage collection - // Note: Don't clear the arrays that are referenced in sections (thisWeekEpisodes, upcomingEpisodes, recentEpisodes, seriesWithoutEpisodes) - // as they would empty the section data - memoryManager.clearObjects(allEpisodes); - - await robustCalendarCache.setCachedCalendarData( - sections, - libraryItems, - { watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows } - ); - - } catch (error) { - logger.error('[CalendarData] Error fetching calendar data:', error); - await robustCalendarCache.setCachedCalendarData( - [], - libraryItems, - { watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows }, - true - ); - } finally { - // Force garbage collection after processing - memoryManager.forceGarbageCollection(); + if (cachedData) { + setCalendarData(cachedData); setLoading(false); + return; } - }, [libraryItems, traktAuthenticated, watchlistShows, continueWatching, watchedShows]); + } - useEffect(() => { - if (!libraryLoading && !traktLoading) { - if (traktAuthenticated && (!watchlistShows || !continueWatching || !watchedShows)) { - loadAllCollections(); - } else { - fetchCalendarData(); + const librarySeries = libraryItems.filter(item => item.type === 'series'); + + // Prioritize series sources: Continue Watching > Watchlist > Library > Watched + // This ensures that shows the user is actively watching or interested in are checked first + // before hitting the series limit. + let allSeries: StreamingContent[] = []; + const addedIds = new Set(); + + // Helper to add series if not already added + const addSeries = (id: string, name: string, year: number, poster: string, source: 'watchlist' | 'continue-watching' | 'watched' | 'library') => { + if (!addedIds.has(id)) { + addedIds.add(id); + allSeries.push({ + id, + name, + type: 'series', + poster, + year, + traktSource: source as any // Cast to any to avoid strict type issues with 'library' which might not be in the interface + }); + } + }; + + if (traktAuthenticated) { + // 1. Continue Watching (Highest Priority) + if (continueWatching) { + for (const item of continueWatching) { + if (item.type === 'episode' && item.show && item.show.ids.imdb) { + addSeries( + item.show.ids.imdb, + item.show.title, + item.show.year, + '', // Poster will be fetched if missing + 'continue-watching' + ); } - } else if (!libraryLoading && !traktAuthenticated) { - fetchCalendarData(); + } } - }, [libraryItems, libraryLoading, traktLoading, traktAuthenticated, watchlistShows, continueWatching, watchedShows, fetchCalendarData, loadAllCollections]); - const refresh = useCallback((force = false) => { - fetchCalendarData(force); - }, [fetchCalendarData]); + // 2. Watchlist + if (watchlistShows) { + for (const item of watchlistShows) { + if (item.show && item.show.ids.imdb) { + addSeries( + item.show.ids.imdb, + item.show.title, + item.show.year, + '', + 'watchlist' + ); + } + } + } + } + // 3. Library + for (const item of librarySeries) { + addSeries( + item.id, + item.name, + item.year || 0, + item.poster, + 'library' + ); + } - return { - calendarData, - loading, - refresh, - }; + // 4. Watched (Lowest Priority) + if (traktAuthenticated && watchedShows) { + const recentWatched = watchedShows.slice(0, 20); + for (const item of recentWatched) { + if (item.show && item.show.ids.imdb) { + addSeries( + item.show.ids.imdb, + item.show.title, + item.show.year, + '', + 'watched' + ); + } + } + } + + // Limit the number of series to prevent memory overflow + const maxSeries = 300; // Increased from 100 to 300 to accommodate larger libraries + if (allSeries.length > maxSeries) { + logger.warn(`[CalendarData] Too many series (${allSeries.length}), limiting to ${maxSeries} to prevent memory issues`); + allSeries = allSeries.slice(0, maxSeries); + } + + logger.log(`[CalendarData] Total series to check: ${allSeries.length}`); + + let allEpisodes: CalendarEpisode[] = []; + let seriesWithoutEpisodes: CalendarEpisode[] = []; + + // Process series in memory-efficient batches to prevent OOM + const processedSeries = await memoryManager.processArrayInBatches( + allSeries, + async (series: StreamingContent, index: number) => { + try { + // Use the new memory-efficient method to fetch upcoming and recent episodes + const episodeData = await stremioService.getUpcomingEpisodes(series.type, series.id, { + daysBack: 90, // 3 months back for recently released episodes + daysAhead: 60, // 2 months ahead for upcoming episodes + maxEpisodes: 50, // Increased limit to get more episodes per series + }); + + if (episodeData && episodeData.episodes.length > 0) { + const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id); + let tmdbEpisodes: { [key: string]: any } = {}; + + // Only fetch TMDB data if we need it and limit it + if (tmdbId && episodeData.episodes.length > 0) { + try { + // Get only current and next season to limit memory usage + const seasons = [...new Set(episodeData.episodes.map(ep => ep.season || 1))]; + const limitedSeasons = seasons.slice(0, 3); // Limit to 3 seasons max + + for (const seasonNum of limitedSeasons) { + const seasonEpisodes = await tmdbService.getSeasonDetails(tmdbId, seasonNum); + if (seasonEpisodes?.episodes) { + seasonEpisodes.episodes.forEach((episode: any) => { + const key = `${episode.season_number}:${episode.episode_number}`; + tmdbEpisodes[key] = episode; + }); + } + } + } catch (tmdbError) { + logger.warn(`[CalendarData] TMDB fetch failed for ${series.name}, continuing without additional metadata`); + } + } + + // Transform episodes with memory-efficient processing + const transformedEpisodes = episodeData.episodes.map(video => { + const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {}; + const episode = { + id: video.id, + seriesId: series.id, + title: tmdbEpisode.name || video.title || `Episode ${video.episode}`, + seriesName: series.name || episodeData.seriesName, + poster: series.poster || episodeData.poster || '', + releaseDate: video.released, + season: video.season || 0, + episode: video.episode || 0, + overview: tmdbEpisode.overview || '', + vote_average: tmdbEpisode.vote_average || 0, + still_path: tmdbEpisode.still_path || null, + season_poster_path: tmdbEpisode.season_poster_path || null + }; + + + return episode; + }); + + // Clear references to help garbage collection + memoryManager.clearObjects(tmdbEpisodes); + + return { type: 'episodes', data: transformedEpisodes }; + } else { + return { + type: 'no-episodes', + data: { + id: series.id, + seriesId: series.id, + title: 'No upcoming episodes', + seriesName: series.name || episodeData?.seriesName || '', + poster: series.poster || episodeData?.poster || '', + releaseDate: '', + season: 0, + episode: 0, + overview: '', + vote_average: 0, + still_path: null, + season_poster_path: null + } + }; + } + } catch (error) { + logger.error(`[CalendarData] Error fetching episodes for ${series.name}:`, error); + return { + type: 'no-episodes', + data: { + id: series.id, + seriesId: series.id, + title: 'No upcoming episodes', + seriesName: series.name || '', + poster: series.poster || '', + releaseDate: '', + season: 0, + episode: 0, + overview: '', + vote_average: 0, + still_path: null, + season_poster_path: null + } + }; + } + }, + 5, // Small batch size to prevent memory spikes + 100 // Small delay between batches + ); + + // Process results and separate episodes from no-episode series + for (const result of processedSeries) { + if (!result) { + logger.error(`[CalendarData] Null/undefined result in processedSeries`); + continue; + } + + if (result.type === 'episodes' && Array.isArray(result.data)) { + allEpisodes.push(...result.data); + } else if (result.type === 'no-episodes' && result.data) { + seriesWithoutEpisodes.push(result.data as CalendarEpisode); + } else { + logger.warn(`[CalendarData] Unexpected result type or missing data:`, result); + } + } + + // Clear processed series to free memory + memoryManager.clearObjects(processedSeries); + + // Limit total episodes to prevent memory overflow + allEpisodes = memoryManager.limitArraySize(allEpisodes, 500); + seriesWithoutEpisodes = memoryManager.limitArraySize(seriesWithoutEpisodes, 100); + + // Sort episodes by release date with error handling + allEpisodes.sort((a, b) => { + try { + const dateA = new Date(a.releaseDate).getTime(); + const dateB = new Date(b.releaseDate).getTime(); + return dateA - dateB; + } catch (error) { + logger.warn(`[CalendarData] Error sorting episodes: ${a.releaseDate} vs ${b.releaseDate}`, error); + return 0; // Keep original order if sorting fails + } + }); + + logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`); + + // Use memory-efficient filtering with error handling + const thisWeekEpisodes = await memoryManager.filterLargeArray( + allEpisodes, + ep => { + try { + if (!ep.releaseDate) return false; + const parsed = parseISO(ep.releaseDate); + // Show all episodes for this week, including released ones + return isThisWeek(parsed); + } catch (error) { + logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error); + return false; + } + } + ); + + const upcomingEpisodes = await memoryManager.filterLargeArray( + allEpisodes, + ep => { + try { + if (!ep.releaseDate) return false; + const parsed = parseISO(ep.releaseDate); + // Show upcoming episodes that are NOT this week + return isAfter(parsed, new Date()) && !isThisWeek(parsed); + } catch (error) { + logger.warn(`[CalendarData] Error parsing date for upcoming filtering: ${ep.releaseDate}`, error); + return false; + } + } + ); + + const recentEpisodes = await memoryManager.filterLargeArray( + allEpisodes, + ep => { + try { + if (!ep.releaseDate) return false; + const parsed = parseISO(ep.releaseDate); + // Show past episodes that are NOT this week + return isBefore(parsed, new Date()) && !isThisWeek(parsed); + } catch (error) { + logger.warn(`[CalendarData] Error parsing date for recent filtering: ${ep.releaseDate}`, error); + return false; + } + } + ); + + logger.log(`[CalendarData] Episode categorization: This Week: ${thisWeekEpisodes.length}, Upcoming: ${upcomingEpisodes.length}, Recently Released: ${recentEpisodes.length}, Series without episodes: ${seriesWithoutEpisodes.length}`); + + // Debug: Show some example episodes from each category + if (thisWeekEpisodes && thisWeekEpisodes.length > 0) { + logger.log(`[CalendarData] This Week examples:`, thisWeekEpisodes.slice(0, 3).map(ep => ({ + title: ep.title, + date: ep.releaseDate, + series: ep.seriesName + }))); + } + if (recentEpisodes && recentEpisodes.length > 0) { + logger.log(`[CalendarData] Recently Released examples:`, recentEpisodes.slice(0, 3).map(ep => ({ + title: ep.title, + date: ep.releaseDate, + series: ep.seriesName + }))); + } + + const sections: CalendarSection[] = []; + if (thisWeekEpisodes.length > 0) { + sections.push({ title: 'This Week', data: thisWeekEpisodes }); + logger.log(`[CalendarData] Added 'This Week' section with ${thisWeekEpisodes.length} episodes`); + } + if (upcomingEpisodes.length > 0) { + sections.push({ title: 'Upcoming', data: upcomingEpisodes }); + logger.log(`[CalendarData] Added 'Upcoming' section with ${upcomingEpisodes.length} episodes`); + } + if (recentEpisodes.length > 0) { + sections.push({ title: 'Recently Released', data: recentEpisodes }); + logger.log(`[CalendarData] Added 'Recently Released' section with ${recentEpisodes.length} episodes`); + } + if (seriesWithoutEpisodes.length > 0) { + sections.push({ title: 'Series with No Scheduled Episodes', data: seriesWithoutEpisodes }); + logger.log(`[CalendarData] Added 'Series with No Scheduled Episodes' section with ${seriesWithoutEpisodes.length} episodes`); + } + + // Log section details before setting + logger.log(`[CalendarData] About to set calendarData with ${sections.length} sections:`); + sections.forEach((section, index) => { + logger.log(` Section ${index}: "${section.title}" with ${section.data?.length || 0} episodes`); + }); + + setCalendarData(sections); + + // Clear large arrays to help garbage collection + // Note: Don't clear the arrays that are referenced in sections (thisWeekEpisodes, upcomingEpisodes, recentEpisodes, seriesWithoutEpisodes) + // as they would empty the section data + memoryManager.clearObjects(allEpisodes); + + await robustCalendarCache.setCachedCalendarData( + sections, + libraryItems, + { watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows } + ); + + } catch (error) { + logger.error('[CalendarData] Error fetching calendar data:', error); + await robustCalendarCache.setCachedCalendarData( + [], + libraryItems, + { watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows }, + true + ); + } finally { + // Force garbage collection after processing + memoryManager.forceGarbageCollection(); + setLoading(false); + } + }, [libraryItems, traktAuthenticated, watchlistShows, continueWatching, watchedShows]); + + useEffect(() => { + if (!libraryLoading && !traktLoading) { + if (traktAuthenticated && (!watchlistShows || !continueWatching || !watchedShows)) { + loadAllCollections(); + } else { + fetchCalendarData(); + } + } else if (!libraryLoading && !traktAuthenticated) { + fetchCalendarData(); + } + }, [libraryItems, libraryLoading, traktLoading, traktAuthenticated, watchlistShows, continueWatching, watchedShows, fetchCalendarData, loadAllCollections]); + + const refresh = useCallback((force = false) => { + fetchCalendarData(force); + }, [fetchCalendarData]); + + return { + calendarData, + loading, + refresh, + }; }; - \ No newline at end of file diff --git a/temp_libtorrent b/temp_libtorrent new file mode 160000 index 00000000..b22f2a38 --- /dev/null +++ b/temp_libtorrent @@ -0,0 +1 @@ +Subproject commit b22f2a386d86fbb31a5f60af62153e9ce77390a5 From ecaaaa66ed3ca127fa8af58f2f744cd2359335d3 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 25 Nov 2025 01:15:41 +0530 Subject: [PATCH 05/15] ksplayer word splitting fix --- src/components/player/KSPlayerCore.tsx | 521 +++++++++--------- .../player/subtitles/CustomSubtitles.tsx | 41 +- src/components/player/utils/playerUtils.ts | 18 +- 3 files changed, 289 insertions(+), 291 deletions(-) diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index da6c0bf9..d9cbf1b5 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -116,7 +116,7 @@ const KSPlayerCore: React.FC = () => { // Use window dimensions for iPad instead of screen dimensions const windowData = Dimensions.get('window'); const effectiveDimensions = shouldUseFullscreen ? windowData : screenData; - + // Helper to get appropriate dimensions for gesture areas and overlays const getDimensions = () => ({ width: shouldUseFullscreen ? windowData.width : screenDimensions.width, @@ -328,7 +328,7 @@ const KSPlayerCore: React.FC = () => { id: id || 'placeholder', type: type || 'movie' }); - const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} }; + const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => { } }; const { settings } = useSettings(); // Logo animation values @@ -380,7 +380,7 @@ const KSPlayerCore: React.FC = () => { } } }, [metadata]); - + // Log video source configuration with headers useEffect(() => { console.log('[KSPlayerCore] Video source configured with:', { @@ -417,19 +417,19 @@ const KSPlayerCore: React.FC = () => { : (metadataGroupedEpisodes || {}); const allEpisodes = Object.values(sourceGroups || {}).flat() as any[]; if (!allEpisodes || allEpisodes.length === 0) return null; - + // First try next episode in same season - let nextEp = allEpisodes.find((ep: any) => + let nextEp = allEpisodes.find((ep: any) => ep.season_number === season && ep.episode_number === episode + 1 ); - + // If not found, try first episode of next season if (!nextEp) { - nextEp = allEpisodes.find((ep: any) => + nextEp = allEpisodes.find((ep: any) => ep.season_number === season + 1 && ep.episode_number === 1 ); } - + if (DEBUG_MODE) { logger.log('[KSPlayerCore] nextEpisode computation', { fromRouteGroups: !!(groupedEpisodes && Object.keys(groupedEpisodes || {}).length), @@ -504,12 +504,12 @@ const KSPlayerCore: React.FC = () => { // Long press gesture handlers for speed boost const onLongPressActivated = useCallback(() => { if (!holdToSpeedEnabled) return; - + if (!isSpeedBoosted && playbackSpeed !== holdToSpeedValue) { setOriginalSpeed(playbackSpeed); setPlaybackSpeed(holdToSpeedValue); setIsSpeedBoosted(true); - + // Show "Activated" overlay setShowSpeedActivatedOverlay(true); Animated.spring(speedActivatedOverlayOpacity, { @@ -518,7 +518,7 @@ const KSPlayerCore: React.FC = () => { friction: 8, useNativeDriver: true, }).start(); - + // Auto-hide after 2 seconds setTimeout(() => { Animated.timing(speedActivatedOverlayOpacity, { @@ -529,7 +529,7 @@ const KSPlayerCore: React.FC = () => { setShowSpeedActivatedOverlay(false); }); }, 2000); - + logger.log(`[KSPlayerCore] Speed boost activated: ${holdToSpeedValue}x`); } }, [isSpeedBoosted, playbackSpeed, holdToSpeedEnabled, holdToSpeedValue, speedActivatedOverlayOpacity]); @@ -559,7 +559,7 @@ const KSPlayerCore: React.FC = () => { useEffect(() => { return () => { if (isSpeedBoosted) { - try { setPlaybackSpeed(originalSpeed); } catch {} + try { setPlaybackSpeed(originalSpeed); } catch { } } }; }, [isSpeedBoosted, originalSpeed]); @@ -648,7 +648,7 @@ const KSPlayerCore: React.FC = () => { if (isOpeningAnimationComplete) { enableImmersiveMode(); } - return () => {}; + return () => { }; }, [isOpeningAnimationComplete]) ); @@ -713,7 +713,7 @@ const KSPlayerCore: React.FC = () => { const completeOpeningAnimation = () => { // Stop the pulse animation immediately pulseAnim.stopAnimation(); - + Animated.parallel([ Animated.timing(openingFadeAnim, { toValue: 1, @@ -870,7 +870,7 @@ const KSPlayerCore: React.FC = () => { const timeInSeconds = duration > 0 ? Math.max(0, Math.min(rawSeconds, duration - END_EPSILON)) : Math.max(0, rawSeconds); - + if (DEBUG_MODE) { if (__DEV__) logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`); } @@ -987,7 +987,7 @@ const KSPlayerCore: React.FC = () => { setIsPlayerReady(true); completeOpeningAnimation(); } - + // If time is advancing right after seek and we previously intended to play, // ensure paused state is false to keep UI in sync if (wasPlayingBeforeDragRef.current && paused && !isDragging) { @@ -995,40 +995,40 @@ const KSPlayerCore: React.FC = () => { // Reset the intent once corrected wasPlayingBeforeDragRef.current = false; } - + // Periodic check for disabled audio track (every 3 seconds, max 3 attempts) const now = Date.now(); if (now - lastAudioTrackCheck > 3000 && !paused && duration > 0 && audioTrackFallbackAttempts < 3) { setLastAudioTrackCheck(now); - + // Check if audio track is disabled (-1) and we have available tracks if (selectedAudioTrack === -1 && ksAudioTracks.length > 1) { logger.warn('[VideoPlayer] Detected disabled audio track, attempting fallback'); - + // Find a fallback audio track (prefer stereo/standard formats) const fallbackTrack = ksAudioTracks.find((track, index) => { const trackName = (track.name || '').toLowerCase(); const trackLang = (track.language || '').toLowerCase(); // Prefer stereo, AAC, or standard audio formats, avoid heavy codecs - return !trackName.includes('truehd') && - !trackName.includes('dts') && - !trackName.includes('dolby') && - !trackName.includes('atmos') && - !trackName.includes('7.1') && - !trackName.includes('5.1') && - index !== selectedAudioTrack; // Don't select the same track + return !trackName.includes('truehd') && + !trackName.includes('dts') && + !trackName.includes('dolby') && + !trackName.includes('atmos') && + !trackName.includes('7.1') && + !trackName.includes('5.1') && + index !== selectedAudioTrack; // Don't select the same track }); - + if (fallbackTrack) { const fallbackIndex = ksAudioTracks.indexOf(fallbackTrack); logger.warn(`[VideoPlayer] Switching to fallback audio track: ${fallbackTrack.name || 'Unknown'} (index: ${fallbackIndex})`); - + // Increment fallback attempts counter setAudioTrackFallbackAttempts(prev => prev + 1); - + // Switch to fallback audio track setSelectedAudioTrack(fallbackIndex); - + // Brief pause to allow track switching setPaused(true); setTimeout(() => { @@ -1129,33 +1129,33 @@ const KSPlayerCore: React.FC = () => { }); }); } - + const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => { const trackIndex = track.id !== undefined ? track.id : index; - + // Build comprehensive track name from available fields let trackName = ''; const parts = []; - + // Add language if available let language = track.language || track.languageCode; - + if (language && language !== 'Unknown' && language !== 'und' && language !== '') { parts.push(language.toUpperCase()); } - + // Add bitrate if available const bitrate = track.bitRate; if (bitrate && bitrate > 0) { parts.push(`${Math.round(bitrate / 1000)}kbps`); } - + // Add bit depth if available const bitDepth = track.bitDepth; if (bitDepth && bitDepth > 0) { parts.push(`${bitDepth}bit`); } - + // Add track name if available and not generic let title = track.name; if (title && !title.match(/^(Audio|Track)\s*\d*$/i) && title !== 'Unknown') { @@ -1165,7 +1165,7 @@ const KSPlayerCore: React.FC = () => { parts.push(title); } } - + // Combine parts or fallback to generic name if (parts.length > 0) { trackName = parts.join(' • '); @@ -1178,9 +1178,9 @@ const KSPlayerCore: React.FC = () => { trackName = `Audio ${index + 1}`; } } - + const trackLanguage = language || 'Unknown'; - + if (DEBUG_MODE) { logger.log(`[VideoPlayer] Processed KSPlayer track ${index}:`, { id: trackIndex, @@ -1191,7 +1191,7 @@ const KSPlayerCore: React.FC = () => { bitDepth: bitDepth }); } - + return { id: trackIndex, // Use the actual track ID from KSPlayer name: trackName, @@ -1199,19 +1199,19 @@ const KSPlayerCore: React.FC = () => { }; }); setKsAudioTracks(formattedAudioTracks); - + // Auto-select English audio track if available, otherwise first track if (selectedAudioTrack === null && formattedAudioTracks.length > 0) { // Look for English track first - const englishTrack = formattedAudioTracks.find((track: {id: number, name: string, language?: string}) => { + const englishTrack = formattedAudioTracks.find((track: { id: number, name: string, language?: string }) => { const lang = (track.language || '').toLowerCase(); - return lang === 'english' || lang === 'en' || lang === 'eng' || - (track.name && track.name.toLowerCase().includes('english')); + return lang === 'english' || lang === 'en' || lang === 'eng' || + (track.name && track.name.toLowerCase().includes('english')); }); - + const selectedTrack = englishTrack || formattedAudioTracks[0]; setSelectedAudioTrack(selectedTrack.id); - + if (DEBUG_MODE) { if (englishTrack) { logger.log(`[VideoPlayer] Auto-selected English audio track: ${selectedTrack.name} (ID: ${selectedTrack.id})`); @@ -1220,7 +1220,7 @@ const KSPlayerCore: React.FC = () => { } } } - + if (DEBUG_MODE) { logger.log(`[VideoPlayer] Formatted audio tracks:`, formattedAudioTracks); } @@ -1234,7 +1234,7 @@ const KSPlayerCore: React.FC = () => { isEnabled: track.isEnabled || false, isImageSubtitle: track.isImageSubtitle || false })); - + setKsTextTracks(formattedTextTracks); // Auto-select English subtitle track if available @@ -1248,7 +1248,7 @@ const KSPlayerCore: React.FC = () => { const lang = (track.language || '').toLowerCase(); const name = (track.name || '').toLowerCase(); return lang === 'english' || lang === 'en' || lang === 'eng' || - name.includes('english') || name.includes('en'); + name.includes('english') || name.includes('en'); }); if (englishTrack) { @@ -1264,7 +1264,7 @@ const KSPlayerCore: React.FC = () => { setIsVideoLoaded(true); setIsPlayerReady(true); - + // Reset audio track fallback attempts when new video loads setAudioTrackFallbackAttempts(0); setLastAudioTrackCheck(0); @@ -1276,7 +1276,7 @@ const KSPlayerCore: React.FC = () => { // Complete opening animation immediately before seeking completeOpeningAnimation(); - + if (initialPosition && !isInitialSeekComplete) { logger.log(`[VideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`); // Reduced timeout from 1000ms to 500ms @@ -1290,9 +1290,9 @@ const KSPlayerCore: React.FC = () => { } }, 500); } - + controlsTimeout.current = setTimeout(hideControls, 5000); - + // Auto-fetch and load English external subtitles if available if (imdbId) { fetchAvailableSubtitles(undefined, true); @@ -1393,9 +1393,9 @@ const KSPlayerCore: React.FC = () => { const isTablet = (Platform as any).isPad === true || Math.min(dw, dh) >= 768; setTimeout(() => { if (isTablet) { - ScreenOrientation.unlockAsync().catch(() => {}); + ScreenOrientation.unlockAsync().catch(() => { }); } else { - ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {}); + ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { }); } }, 50); } @@ -1479,7 +1479,7 @@ const KSPlayerCore: React.FC = () => { const handleError = (error: any) => { try { logger.error('[VideoPlayer] Playback Error:', error); - + // Detect KSPlayer startup timeout and silently retry without UI const errText = typeof error === 'string' ? error @@ -1523,43 +1523,43 @@ const KSPlayerCore: React.FC = () => { } // Check for audio codec errors (TrueHD, DTS, Dolby, etc.) - const isAudioCodecError = + const isAudioCodecError = (error?.message && /(trhd|truehd|true\s?hd|dts|dolby|atmos|e-ac3|ac3)/i.test(error.message)) || (error?.error?.message && /(trhd|truehd|true\s?hd|dts|dolby|atmos|e-ac3|ac3)/i.test(error.error.message)) || (error?.title && /codec not supported/i.test(error.title)); - + // Handle audio codec errors with automatic fallback if (isAudioCodecError && ksAudioTracks.length > 1) { logger.warn('[VideoPlayer] Audio codec error detected, attempting audio track fallback'); - + // Find a fallback audio track (prefer stereo/standard formats) const fallbackTrack = ksAudioTracks.find((track, index) => { const trackName = (track.name || '').toLowerCase(); const trackLang = (track.language || '').toLowerCase(); // Prefer stereo, AAC, or standard audio formats, avoid heavy codecs - return !trackName.includes('truehd') && - !trackName.includes('dts') && - !trackName.includes('dolby') && - !trackName.includes('atmos') && - !trackName.includes('7.1') && - !trackName.includes('5.1') && - index !== selectedAudioTrack; // Don't select the same track + return !trackName.includes('truehd') && + !trackName.includes('dts') && + !trackName.includes('dolby') && + !trackName.includes('atmos') && + !trackName.includes('7.1') && + !trackName.includes('5.1') && + index !== selectedAudioTrack; // Don't select the same track }); - + if (fallbackTrack) { const fallbackIndex = ksAudioTracks.indexOf(fallbackTrack); logger.warn(`[VideoPlayer] Switching to fallback audio track: ${fallbackTrack.name || 'Unknown'} (index: ${fallbackIndex})`); - + // Clear any existing error state if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); errorTimeoutRef.current = null; } setShowErrorModal(false); - + // Switch to fallback audio track setSelectedAudioTrack(fallbackIndex); - + // Brief pause to allow track switching setPaused(true); setTimeout(() => { @@ -1567,11 +1567,11 @@ const KSPlayerCore: React.FC = () => { setPaused(false); } }, 500); - + return; // Don't show error UI, attempt recovery } } - + // Format error details for user display let errorMessage = 'An unknown error occurred'; if (error) { @@ -1589,15 +1589,15 @@ const KSPlayerCore: React.FC = () => { errorMessage = JSON.stringify(error, null, 2); } } - + setErrorDetails(errorMessage); setShowErrorModal(true); - + // Clear any existing timeout if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); } - + // Auto-exit after 5 seconds if user doesn't dismiss errorTimeoutRef.current = setTimeout(() => { handleErrorExit(); @@ -1618,7 +1618,7 @@ const KSPlayerCore: React.FC = () => { } } }; - + const handleErrorExit = () => { if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); @@ -1657,44 +1657,44 @@ const KSPlayerCore: React.FC = () => { logger.log(`[VideoPlayer] Selecting audio track: ${trackId}`); logger.log(`[VideoPlayer] Available tracks:`, ksAudioTracks); } - + // Validate that the track exists const trackExists = ksAudioTracks.some(track => track.id === trackId); if (!trackExists) { logger.error(`[VideoPlayer] Audio track ${trackId} not found in available tracks`); return; } - + // Get the selected track info for logging const selectedTrack = ksAudioTracks.find(track => track.id === trackId); if (selectedTrack && DEBUG_MODE) { logger.log(`[VideoPlayer] Switching to track: ${selectedTrack.name} (${selectedTrack.language})`); - + // Check if this is a multi-channel track that might need downmixing const trackName = selectedTrack.name.toLowerCase(); - const isMultiChannel = trackName.includes('5.1') || trackName.includes('7.1') || - trackName.includes('truehd') || trackName.includes('dts') || - trackName.includes('dolby') || trackName.includes('atmos'); - + const isMultiChannel = trackName.includes('5.1') || trackName.includes('7.1') || + trackName.includes('truehd') || trackName.includes('dts') || + trackName.includes('dolby') || trackName.includes('atmos'); + if (isMultiChannel) { logger.log(`[VideoPlayer] Multi-channel audio track detected: ${selectedTrack.name}`); logger.log(`[VideoPlayer] KSPlayer will apply downmixing to ensure dialogue is audible`); } } - + // If changing tracks, briefly pause to allow smooth transition const wasPlaying = !paused; if (wasPlaying) { setPaused(true); } - + // Set the new audio track setSelectedAudioTrack(trackId); - + if (DEBUG_MODE) { logger.log(`[VideoPlayer] Audio track changed to: ${trackId}`); } - + // Resume playback after a brief delay if it was playing if (wasPlaying) { setTimeout(() => { @@ -1757,9 +1757,9 @@ const KSPlayerCore: React.FC = () => { try { const merged = { ...(saved || {}), subtitleSize: migrated }; await storageService.saveSubtitleSettings(merged); - } catch {} + } catch { } } - try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch {} + try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch { } return; } // If no saved settings, use responsive default @@ -1930,39 +1930,39 @@ const KSPlayerCore: React.FC = () => { if (!nextEpisode || !id || isLoadingNextEpisode) return; setIsLoadingNextEpisode(true); - + try { logger.log('[VideoPlayer] Loading next episode:', nextEpisode); - + // Create episode ID for next episode using stremioId if available, otherwise construct it const nextEpisodeId = nextEpisode.stremioId || `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`; - + logger.log('[VideoPlayer] Fetching streams for next episode:', nextEpisodeId); - + // Import stremio service const stremioService = require('../../services/stremioService').default; - + let bestStream: any = null; let streamFound = false; let completedProviders = 0; const expectedProviders = new Set(); - + // Get installed addons to know how many providers to expect const installedAddons = stremioService.getInstalledAddons(); - const streamAddons = installedAddons.filter((addon: any) => + const streamAddons = installedAddons.filter((addon: any) => addon.resources && addon.resources.includes('stream') ); - + streamAddons.forEach((addon: any) => expectedProviders.add(addon.id)); - + // Collect all streams from all providers for the sources modal const allStreams: { [providerId: string]: { streams: any[]; addonName: string } } = {}; let hasNavigated = false; - + // Fetch streams for next episode await stremioService.getStreams('series', nextEpisodeId, (streams: any, addonId: any, addonName: any, error: any) => { completedProviders++; - + // Always collect streams from this provider for sources modal (even after navigation) if (streams && streams.length > 0) { allStreams[addonId] = { @@ -1970,7 +1970,7 @@ const KSPlayerCore: React.FC = () => { addonName: addonName || addonId }; } - + // Navigate with first good stream found, but continue collecting streams in background if (!hasNavigated && !streamFound && streams && streams.length > 0) { // Sort streams by quality and cache status (prefer cached/debrid streams) @@ -1979,7 +1979,7 @@ const KSPlayerCore: React.FC = () => { const bQuality = parseInt(b.title?.match(/(\d+)p/)?.[1] || '0', 10); const aCached = a.behaviorHints?.cached || false; const bCached = b.behaviorHints?.cached || false; - + // Prioritize cached streams first if (aCached !== bCached) { return aCached ? -1 : 1; @@ -1987,7 +1987,7 @@ const KSPlayerCore: React.FC = () => { // Then sort by quality (higher quality first) return bQuality - aQuality; }); - + bestStream = sortedStreams[0]; streamFound = true; hasNavigated = true; @@ -1997,9 +1997,9 @@ const KSPlayerCore: React.FC = () => { setNextLoadingProvider(addonName || addonId || null); setNextLoadingQuality(qualityText); setNextLoadingTitle(bestStream.name || bestStream.title || null); - + logger.log('[VideoPlayer] Found stream for next episode:', bestStream); - + // Pause current playback to ensure no background player remains active setPaused(true); @@ -2026,14 +2026,14 @@ const KSPlayerCore: React.FC = () => { setIsLoadingNextEpisode(false); }, 100); // Small delay to ensure smooth transition } - + // If we've checked all providers and no stream found if (completedProviders >= expectedProviders.size && !streamFound) { logger.warn('[VideoPlayer] No streams found for next episode after checking all providers'); setIsLoadingNextEpisode(false); } }); - + // Fallback timeout in case providers don't respond setTimeout(() => { if (!streamFound) { @@ -2041,7 +2041,7 @@ const KSPlayerCore: React.FC = () => { setIsLoadingNextEpisode(false); } }, 8000); - + } catch (error) { logger.error('[VideoPlayer] Error loading next episode:', error); setIsLoadingNextEpisode(false); @@ -2078,7 +2078,7 @@ const KSPlayerCore: React.FC = () => { metadataOpacity.setValue(1); metadataScale.setValue(1); } - + Animated.parallel([ Animated.timing(pauseOverlayOpacity, { toValue: 0, @@ -2091,7 +2091,7 @@ const KSPlayerCore: React.FC = () => { useNativeDriver: true, }) ]).start(() => setShowPauseOverlay(false)); - + // Show controls when overlay is touched if (!showControls) { setShowControls(true); @@ -2100,7 +2100,7 @@ const KSPlayerCore: React.FC = () => { duration: 300, useNativeDriver: true, }).start(); - + // Auto-hide controls after 5 seconds if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); @@ -2160,10 +2160,10 @@ const KSPlayerCore: React.FC = () => { if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); } - + // Cleanup gesture controls gestureControls.cleanup(); - + if (startupRetryTimerRef.current) { clearTimeout(startupRetryTimerRef.current); startupRetryTimerRef.current = null; @@ -2193,36 +2193,31 @@ const KSPlayerCore: React.FC = () => { ); const newSubtitle = currentCue ? currentCue.text : ''; setCurrentSubtitle(newSubtitle); - + // Extract formatted segments from current cue if (currentCue?.formattedSegments) { - // Split by newlines to get per-line segments - const lines = (currentCue.text || '').split(/\r?\n/); const segmentsPerLine: SubtitleSegment[][] = []; - let segmentIndex = 0; - - for (const line of lines) { - const lineSegments: SubtitleSegment[] = []; - const words = line.split(/(\s+)/); - - for (const word of words) { - if (word.trim()) { - if (segmentIndex < currentCue.formattedSegments.length) { - lineSegments.push(currentCue.formattedSegments[segmentIndex]); - segmentIndex++; - } else { - // Fallback if segment count doesn't match - lineSegments.push({ text: word }); - } + let currentLine: SubtitleSegment[] = []; + + currentCue.formattedSegments.forEach(seg => { + const parts = seg.text.split(/\r?\n/); + parts.forEach((part, index) => { + if (index > 0) { + // New line found + segmentsPerLine.push(currentLine); + currentLine = []; } - } - - if (lineSegments.length > 0) { - segmentsPerLine.push(lineSegments); - } + if (part.length > 0) { + currentLine.push({ ...seg, text: part }); + } + }); + }); + + if (currentLine.length > 0) { + segmentsPerLine.push(currentLine); } - - setCurrentFormattedSegments(segmentsPerLine.length > 0 ? segmentsPerLine : []); + + setCurrentFormattedSegments(segmentsPerLine); } else { setCurrentFormattedSegments([]); } @@ -2243,14 +2238,14 @@ const KSPlayerCore: React.FC = () => { if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor); if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth); if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right'); - if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset); + if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset); if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing); if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier); if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec); } - } catch {} finally { + } catch { } finally { // Mark subtitle settings as loaded so we can safely persist subsequent changes - try { setSubtitleSettingsLoaded(true); } catch {} + try { setSubtitleSettingsLoaded(true); } catch { } } })(); }, []); @@ -2283,7 +2278,7 @@ const KSPlayerCore: React.FC = () => { subtitleOutlineColor, subtitleOutlineWidth, subtitleAlign, - subtitleBottomOffset, + subtitleBottomOffset, subtitleLetterSpacing, subtitleLineHeightMultiplier, subtitleOffsetSec, @@ -2325,7 +2320,7 @@ const KSPlayerCore: React.FC = () => { // AirPlay handler const handleAirPlayPress = async () => { if (!ksPlayerRef.current) return; - + try { // First ensure AirPlay is enabled if (!allowsAirPlay) { @@ -2333,10 +2328,10 @@ const KSPlayerCore: React.FC = () => { setAllowsAirPlay(true); logger.log(`[VideoPlayer] AirPlay enabled before showing picker`); } - + // Show the AirPlay picker ksPlayerRef.current.showAirPlayPicker(); - + logger.log(`[VideoPlayer] AirPlay picker triggered - check console for native logs`); } catch (error) { logger.error('[VideoPlayer] Error showing AirPlay picker:', error); @@ -2404,15 +2399,15 @@ const KSPlayerCore: React.FC = () => { const handleEpisodeStreamSelect = async (stream: any) => { if (!selectedEpisodeForStreams) return; - + setShowEpisodeStreamsModal(false); - + const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; const newStreamName = stream.name || stream.title || 'Unknown Stream'; - + setPaused(true); - + setTimeout(() => { navigation.replace('PlayerIOS', { uri: stream.url, @@ -2690,11 +2685,11 @@ const KSPlayerCore: React.FC = () => { buffered={buffered} formatTime={formatTime} playerBackend={playerBackend} - cyclePlaybackSpeed={cyclePlaybackSpeed} - currentPlaybackSpeed={playbackSpeed} - isAirPlayActive={isAirPlayActive} - allowsAirPlay={allowsAirPlay} - onAirPlayPress={handleAirPlayPress} + cyclePlaybackSpeed={cyclePlaybackSpeed} + currentPlaybackSpeed={playbackSpeed} + isAirPlayActive={isAirPlayActive} + allowsAirPlay={allowsAirPlay} + onAirPlayPress={handleAirPlayPress} /> {showPauseOverlay && ( @@ -2725,7 +2720,7 @@ const KSPlayerCore: React.FC = () => { @@ -2750,24 +2745,24 @@ const KSPlayerCore: React.FC = () => { }}> {showCastDetails && selectedCastMember ? ( // Cast Detail View with fade transition - - - { }} > - Back to details - - @@ -2838,23 +2833,23 @@ const KSPlayerCore: React.FC = () => { /> )} - - {selectedCastMember.name} {selectedCastMember.character && ( - { as {selectedCastMember.character} )} - + {/* Biography if available */} {selectedCastMember.biography && ( - { ) : ( // Default Metadata View - - You're watching - {title} {!!year && ( - {`${year}${type === 'series' && season && episode ? ` • S${season}E${episode}` : ''}`} )} {!!episodeTitle && ( - {episodeTitle} )} {(currentEpisodeDescription || metadata?.description) && ( - {type === 'series' ? (currentEpisodeDescription || metadata?.description || '') : (metadata?.description || '')} )} {cast && cast.length > 0 && ( - Cast {cast.slice(0, 6).map((castMember: any, index: number) => ( @@ -2948,42 +2943,42 @@ const KSPlayerCore: React.FC = () => { marginRight: 8, marginBottom: 8, }} - onPress={() => { - setSelectedCastMember(castMember); - // Animate metadata out, then cast details in - Animated.parallel([ - Animated.timing(metadataOpacity, { - toValue: 0, - duration: 250, - useNativeDriver: true, - }), - Animated.timing(metadataScale, { - toValue: 0.95, - duration: 250, - useNativeDriver: true, - }) - ]).start(() => { - setShowCastDetails(true); - // Animate cast details in + onPress={() => { + setSelectedCastMember(castMember); + // Animate metadata out, then cast details in Animated.parallel([ - Animated.timing(castDetailsOpacity, { - toValue: 1, - duration: 400, + Animated.timing(metadataOpacity, { + toValue: 0, + duration: 250, useNativeDriver: true, }), - Animated.spring(castDetailsScale, { - toValue: 1, - tension: 80, - friction: 8, + Animated.timing(metadataScale, { + toValue: 0.95, + duration: 250, useNativeDriver: true, }) - ]).start(); - }); - }} + ]).start(() => { + setShowCastDetails(true); + // Animate cast details in + Animated.parallel([ + Animated.timing(castDetailsOpacity, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }), + Animated.spring(castDetailsScale, { + toValue: 1, + tension: 80, + friction: 8, + useNativeDriver: true, + }) + ]).start(); + }); + }} > - {castMember.name} @@ -3065,13 +3060,13 @@ const KSPlayerCore: React.FC = () => { borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.1)', }}> - - + {/* Horizontal Dotted Progress Bar */} { /> ))} - + {/* Progress fill */} { shadowRadius: 2, }} /> - + { borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.1)', }}> - - + {/* Horizontal Dotted Progress Bar */} { /> ))} - + {/* Progress fill */} { shadowRadius: 2, }} /> - + { metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined} onSelectEpisode={handleEpisodeSelect} /> - + { /> )} - + {/* Error Modal */} { - + The video player encountered an error and cannot continue playback: - + { fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace' }}>{errorDetails} - + { }}>Exit Player - + = ({ formattedSegments, }) => { if (!useCustomSubtitles || !currentSubtitle) return null; - + const inverseScale = 1 / zoomScale; const bgColor = subtitleBackground ? `rgba(0, 0, 0, ${Math.min(Math.max(backgroundOpacity, 0), 1)})` : 'transparent'; let effectiveBottom = bottomOffset; @@ -65,23 +65,26 @@ export const CustomSubtitles: React.FC = ({ } effectiveBottom = Math.max(0, effectiveBottom); + // Prepare content lines + const lines = String(currentSubtitle).split(/\r?\n/); + + // Detect RTL for each line + const lineRTLStatus = lines.map(line => detectRTL(line)); + const hasRTL = lineRTLStatus.some(status => status); + // When using crisp outline, prefer SVG text with real stroke instead of blur shadow - const useCrispSvgOutline = outline === true; + // However, SVG text does not support complex text shaping (required for Arabic/RTL), + // so we must fallback to standard Text component for RTL languages. + const useCrispSvgOutline = outline === true && !hasRTL; const shadowStyle = (textShadow && !useCrispSvgOutline) ? { - textShadowColor: 'rgba(0, 0, 0, 0.9)', - textShadowOffset: { width: 2, height: 2 }, - textShadowRadius: 4, - } + textShadowColor: 'rgba(0, 0, 0, 0.9)', + textShadowOffset: { width: 2, height: 2 }, + textShadowRadius: 4, + } : {}; - // Prepare content lines - const lines = String(currentSubtitle).split(/\r?\n/); - - // Detect RTL for each line - const lineRTLStatus = lines.map(line => detectRTL(line)); - const displayFontSize = subtitleSize * inverseScale; const displayLineHeight = subtitleSize * lineHeightMultiplier * inverseScale; const svgHeight = lines.length * displayLineHeight; @@ -89,14 +92,14 @@ export const CustomSubtitles: React.FC = ({ // Helper to render formatted segments const renderFormattedText = (segments: SubtitleSegment[], lineIndex: number, keyPrefix: string, isRTL?: boolean, customLetterSpacing?: number) => { if (!segments || segments.length === 0) return null; - + // For RTL, use a very small negative letter spacing to stretch words slightly // This helps with proper diacritic spacing while maintaining ligatures const effectiveLetterSpacing = isRTL ? (displayFontSize * -0.02) : (customLetterSpacing ?? letterSpacing); // For RTL, adjust text alignment const effectiveAlign = isRTL && align === 'left' ? 'right' : (isRTL && align === 'right' ? 'left' : align); - + return ( = ({ const isRTL = lineRTLStatus.every(status => status); let anchor: 'start' | 'middle' | 'end'; let x: number; - + if (isRTL) { // For RTL, always use 'end' anchor to position from right edge anchor = 'end'; @@ -165,20 +168,20 @@ export const CustomSubtitles: React.FC = ({ anchor = align === 'center' ? 'middle' : align === 'left' ? 'start' : 'end'; x = align === 'center' ? 500 : (align === 'left' ? 0 : 1000); } - + const baseFontSize = displayFontSize; const lineHeightPx = displayLineHeight; const strokeWidth = Math.max(0.5, outlineWidth); // For RTL, use a very small negative letter spacing to stretch words slightly // This helps with proper diacritic spacing while maintaining ligatures const effectiveLetterSpacing = isRTL ? (baseFontSize * -0.02) : letterSpacing; - + // Position text from bottom up - last line should be at svgHeight - small margin // Add descender buffer so letters like y/g/p/q/j aren't clipped const descenderBuffer = baseFontSize * 0.35 + (strokeWidth * 0.5); const lastLineBaselineY = svgHeight - descenderBuffer; const startY = lastLineBaselineY - (lines.length - 1) * lineHeightPx; - + return ( <> {/* Stroke layer */} @@ -239,7 +242,7 @@ export const CustomSubtitles: React.FC = ({ const effectiveLetterSpacing = isRTL ? (subtitleSize * inverseScale * -0.02) : letterSpacing; // For RTL, adjust text alignment const effectiveAlign = isRTL && align === 'left' ? 'right' : (isRTL && align === 'right' ? 'left' : align); - + return ( { }; // Add language code to name mapping -export const languageMap: {[key: string]: string} = { +export const languageMap: { [key: string]: string } = { 'en': 'English', 'eng': 'English', 'es': 'Spanish', @@ -81,10 +81,10 @@ export const formatLanguage = (code?: string): string => { if (!code) return 'Unknown'; const normalized = code.toLowerCase(); const languageName = languageMap[normalized] || code.toUpperCase(); - + // If the result is still the uppercased code, it means we couldn't find it in our map. if (languageName === code.toUpperCase()) { - return `Unknown (${code})`; + return `Unknown (${code})`; } return languageName; @@ -103,8 +103,8 @@ export const getTrackDisplayName = (track: { name?: string, id: number, language } // If the track name contains detailed information (like codec, bitrate, etc.), use it as-is - if (track.name && (track.name.includes('DDP') || track.name.includes('DTS') || track.name.includes('AAC') || - track.name.includes('Kbps') || track.name.includes('Atmos') || track.name.includes('~'))) { + if (track.name && (track.name.includes('DDP') || track.name.includes('DTS') || track.name.includes('AAC') || + track.name.includes('Kbps') || track.name.includes('Atmos') || track.name.includes('~'))) { return track.name; } @@ -159,7 +159,7 @@ export const formatTime = (seconds: number) => { const hours = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); - + if (hours > 0) { return `${hours}:${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`; } else { @@ -189,14 +189,14 @@ export const detectRTL = (text: string): boolean => { // Arabic Presentation Forms-B: U+FE70–U+FEFF // Hebrew: U+0590–U+05FF // Persian/Urdu use Arabic script (no separate range) - const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/; - + const rtlRegex = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/g; + // Remove whitespace and count characters const nonWhitespace = text.replace(/\s/g, ''); if (nonWhitespace.length === 0) return false; const rtlCount = (nonWhitespace.match(rtlRegex) || []).length; - + // Consider RTL if at least 30% of non-whitespace characters are RTL // This handles mixed-language subtitles (e.g., Arabic with English numbers) return rtlCount / nonWhitespace.length >= 0.3; From 348cbf86d85f8931b2dcad8be9124d645316c6a8 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 25 Nov 2025 01:17:23 +0530 Subject: [PATCH 06/15] android player wordsplitting fix --- src/components/player/AndroidVideoPlayer.tsx | 1055 +++++++++--------- 1 file changed, 525 insertions(+), 530 deletions(-) diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 4e94da40..d7572f12 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -19,13 +19,13 @@ import { useMetadata } from '../../hooks/useMetadata'; import { useSettings } from '../../hooks/useSettings'; import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; -import { +import { DEFAULT_SUBTITLE_SIZE, getDefaultSubtitleSize, AudioTrack, TextTrack, - ResizeModeType, - WyzieSubtitle, + ResizeModeType, + WyzieSubtitle, SubtitleCue, SubtitleSegment, RESUME_PREF_KEY, @@ -70,7 +70,7 @@ const AndroidVideoPlayer: React.FC = () => { const navigation = useNavigation(); const insets = useSafeAreaInsets(); const route = useRoute>(); - + const { uri, title = 'Episode Name', @@ -115,9 +115,9 @@ const AndroidVideoPlayer: React.FC = () => { // Check if the stream is HLS (m3u8 playlist) const isHlsStream = (url: string) => { - return url.includes('.m3u8') || url.includes('m3u8') || - url.includes('hls') || url.includes('playlist') || - (currentVideoType && currentVideoType.toLowerCase() === 'm3u8'); + return url.includes('.m3u8') || url.includes('m3u8') || + url.includes('hls') || url.includes('playlist') || + (currentVideoType && currentVideoType.toLowerCase() === 'm3u8'); }; // HLS-specific headers for better ExoPlayer compatibility @@ -226,8 +226,8 @@ const AndroidVideoPlayer: React.FC = () => { const [isBackdropLoaded, setIsBackdropLoaded] = useState(false); const backdropImageOpacityAnim = useRef(new Animated.Value(0)).current; const [isBuffering, setIsBuffering] = useState(false); - const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState>([]); - const [rnVideoTextTracks, setRnVideoTextTracks] = useState>([]); + const [rnVideoAudioTracks, setRnVideoAudioTracks] = useState>([]); + const [rnVideoTextTracks, setRnVideoTextTracks] = useState>([]); // Speed boost state for hold-to-speed-up feature const [isSpeedBoosted, setIsSpeedBoosted] = useState(false); @@ -300,19 +300,19 @@ const AndroidVideoPlayer: React.FC = () => { try { // Check if URL is already properly formatted const urlObj = new URL(url); - + // Handle special characters in the pathname that might cause issues const pathname = urlObj.pathname; const search = urlObj.search; const hash = urlObj.hash; - + // Decode and re-encode the pathname to handle double-encoding const decodedPathname = decodeURIComponent(pathname); const encodedPathname = encodeURI(decodedPathname); - + // Reconstruct the URL const processedUrl = `${urlObj.protocol}//${urlObj.host}${encodedPathname}${search}${hash}`; - + logger.log(`[AndroidVideoPlayer][VLC] URL processed: ${url} -> ${processedUrl}`); return processedUrl; } catch (error) { @@ -323,8 +323,8 @@ const AndroidVideoPlayer: React.FC = () => { // VLC track state - will be managed by VlcVideoPlayer component - const [vlcAudioTracks, setVlcAudioTracks] = useState>([]); - const [vlcSubtitleTracks, setVlcSubtitleTracks] = useState>([]); + const [vlcAudioTracks, setVlcAudioTracks] = useState>([]); + const [vlcSubtitleTracks, setVlcSubtitleTracks] = useState>([]); const [vlcSelectedAudioTrack, setVlcSelectedAudioTrack] = useState(undefined); const [vlcSelectedSubtitleTrack, setVlcSelectedSubtitleTrack] = useState(undefined); const [vlcRestoreTime, setVlcRestoreTime] = useState(undefined); // Time to restore after remount @@ -357,8 +357,8 @@ const AndroidVideoPlayer: React.FC = () => { useVLC ? (vlcSelectedAudioTrack ?? null) : (selectedAudioTrack?.type === SelectedTrackType.INDEX && selectedAudioTrack.value !== undefined - ? Number(selectedAudioTrack.value) - : null), + ? Number(selectedAudioTrack.value) + : null), [useVLC, vlcSelectedAudioTrack, selectedAudioTrack] ); @@ -484,7 +484,7 @@ const AndroidVideoPlayer: React.FC = () => { const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {}); const [currentStreamUrl, setCurrentStreamUrl] = useState(uri); const [currentVideoType, setCurrentVideoType] = useState(videoType); - + // Memoized processed URL for VLC to prevent infinite loops const processedStreamUrl = useMemo(() => { return useVLC ? processUrlForVLC(currentStreamUrl) : currentStreamUrl; @@ -629,13 +629,13 @@ const AndroidVideoPlayer: React.FC = () => { const shouldLoadMetadata = Boolean(id && type); const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) }); const { settings: appSettings } = useSettings(); - const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => {} }; - + const { metadata, loading: metadataLoading, groupedEpisodes: metadataGroupedEpisodes, cast, loadCast } = shouldLoadMetadata ? (metadataResult as any) : { metadata: null, loading: false, groupedEpisodes: {}, cast: [], loadCast: () => { } }; + // Logo animation values const logoScaleAnim = useRef(new Animated.Value(0.8)).current; const logoOpacityAnim = useRef(new Animated.Value(0)).current; const pulseAnim = useRef(new Animated.Value(1)).current; - + // Check if we have a logo to show const hasLogo = metadata && metadata.logo && !metadataLoading; @@ -710,12 +710,12 @@ const AndroidVideoPlayer: React.FC = () => { const allEpisodes = Object.values(sourceGroups || {}).flat() as any[]; if (!allEpisodes || allEpisodes.length === 0) return null; // First try next episode in same season - let nextEp = allEpisodes.find((ep: any) => + let nextEp = allEpisodes.find((ep: any) => ep.season_number === season && ep.episode_number === episode + 1 ); // If not found, try first episode of next season if (!nextEp) { - nextEp = allEpisodes.find((ep: any) => + nextEp = allEpisodes.find((ep: any) => ep.season_number === season + 1 && ep.episode_number === 1 ); } @@ -736,7 +736,7 @@ const AndroidVideoPlayer: React.FC = () => { return null; } }, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]); - + // Small offset (in seconds) used to avoid seeking to the *exact* end of the // file which triggers the `onEnd` callback and causes playback to restart. const END_EPSILON = 0.3; @@ -767,12 +767,12 @@ const AndroidVideoPlayer: React.FC = () => { // Long press gesture handlers for speed boost const onLongPressActivated = useCallback(() => { if (!holdToSpeedEnabled) return; - + if (!isSpeedBoosted && playbackSpeed !== holdToSpeedValue) { setOriginalSpeed(playbackSpeed); setPlaybackSpeed(holdToSpeedValue); setIsSpeedBoosted(true); - + // Show "Activated" overlay setShowSpeedActivatedOverlay(true); Animated.spring(speedActivatedOverlayOpacity, { @@ -781,7 +781,7 @@ const AndroidVideoPlayer: React.FC = () => { friction: 8, useNativeDriver: true, }).start(); - + // Auto-hide after 2 seconds setTimeout(() => { Animated.timing(speedActivatedOverlayOpacity, { @@ -792,7 +792,7 @@ const AndroidVideoPlayer: React.FC = () => { setShowSpeedActivatedOverlay(false); }); }, 2000); - + logger.log(`[AndroidVideoPlayer] Speed boost activated: ${holdToSpeedValue}x`); } }, [isSpeedBoosted, playbackSpeed, holdToSpeedEnabled, holdToSpeedValue, speedActivatedOverlayOpacity]); @@ -823,7 +823,7 @@ const AndroidVideoPlayer: React.FC = () => { return () => { if (isSpeedBoosted) { // best-effort restoration on unmount - try { setPlaybackSpeed(originalSpeed); } catch {} + try { setPlaybackSpeed(originalSpeed); } catch { } } }; }, [isSpeedBoosted, originalSpeed]); @@ -857,14 +857,14 @@ const AndroidVideoPlayer: React.FC = () => { StatusBar.setHidden(true, 'none'); enableImmersiveMode(); startOpeningAnimation(); - + // Initialize current volume and brightness levels // Volume starts at 1.0 (full volume) - React Native Video handles this natively setVolume(1.0); if (DEBUG_MODE) { logger.log(`[AndroidVideoPlayer] Initial volume: 1.0 (native)`); } - + try { // Capture Android system brightness and mode to restore later if (Platform.OS === 'android') { @@ -916,7 +916,7 @@ const AndroidVideoPlayer: React.FC = () => { setVlcKey(`vlc-focus-${Date.now()}`); }, 100); } - return () => {}; + return () => { }; }, [useVLC]) ); @@ -984,7 +984,7 @@ const AndroidVideoPlayer: React.FC = () => { useNativeDriver: true, }), ]).start(); - + // Continuous pulse animation for the logo const createPulseAnimation = () => { return Animated.sequence([ @@ -1000,7 +1000,7 @@ const AndroidVideoPlayer: React.FC = () => { }), ]); }; - + const loopPulse = () => { createPulseAnimation().start(() => { if (!isOpeningAnimationComplete) { @@ -1008,7 +1008,7 @@ const AndroidVideoPlayer: React.FC = () => { } }); }; - + // Start pulsing immediately without delay // Removed the 800ms delay loopPulse(); @@ -1017,7 +1017,7 @@ const AndroidVideoPlayer: React.FC = () => { const completeOpeningAnimation = () => { // Stop the pulse animation immediately pulseAnim.stopAnimation(); - + Animated.parallel([ Animated.timing(openingFadeAnim, { toValue: 1, @@ -1036,13 +1036,13 @@ const AndroidVideoPlayer: React.FC = () => { }), ]).start(() => { setIsOpeningAnimationComplete(true); - + // Delay hiding the overlay to allow background fade animation to complete setTimeout(() => { setShouldHideOpeningOverlay(true); }, 450); // Slightly longer than the background fade duration }); - + // Fallback: ensure animation completes even if something goes wrong setTimeout(() => { if (!isOpeningAnimationComplete) { @@ -1059,11 +1059,11 @@ const AndroidVideoPlayer: React.FC = () => { if (__DEV__) logger.log(`[AndroidVideoPlayer] Loading watch progress for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); const savedProgress = await storageService.getWatchProgress(id, type, episodeId); if (__DEV__) logger.log(`[AndroidVideoPlayer] Saved progress:`, savedProgress); - + if (savedProgress) { const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; if (__DEV__) logger.log(`[AndroidVideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`); - + if (progressPercent < 85) { setResumePosition(savedProgress.currentTime); setSavedDuration(savedProgress.duration); @@ -1104,7 +1104,7 @@ const AndroidVideoPlayer: React.FC = () => { }; try { await storageService.setWatchProgress(id, type, progress, episodeId); - + // Sync to Trakt if authenticated await traktAutosync.handleProgressUpdate(currentTime, duration); } catch (error) { @@ -1112,20 +1112,20 @@ const AndroidVideoPlayer: React.FC = () => { } } }; - + useEffect(() => { if (id && type && !paused && duration > 0) { if (progressSaveInterval) { clearInterval(progressSaveInterval); } - + // Sync interval for progress updates - increased from 5s to 10s to reduce overhead const syncInterval = 10000; // 10 seconds for better performance - + const interval = setInterval(() => { saveWatchProgress(); }, syncInterval); - + setProgressSaveInterval(interval); return () => { clearInterval(interval); @@ -1237,7 +1237,7 @@ const AndroidVideoPlayer: React.FC = () => { } } }; - + // Slider callback functions for React Native Community Slider const handleSliderValueChange = useCallback((value: number) => { if (isDragging && duration > 0) { @@ -1295,16 +1295,16 @@ const AndroidVideoPlayer: React.FC = () => { controlsTimeout.current = setTimeout(hideControls, 5000); } }, [isDragging, showControls]); - + // Removed processProgressTouch - no longer needed with React Native Community Slider const handleProgress = (data: any) => { // Prevent processing progress updates when component is unmounted or app is backgrounded // This prevents Fabric from attempting to update props on detached native views if (isDragging || isSeeking.current || !isMounted.current || isAppBackgrounded.current) return; - + const currentTimeInSeconds = data.currentTime; - + // Update time less frequently for better performance (increased threshold from 0.1s to 0.5s) if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { safeSetState(() => setCurrentTime(currentTimeInSeconds)); @@ -1312,7 +1312,7 @@ const AndroidVideoPlayer: React.FC = () => { const bufferedTime = data.playableDuration || currentTimeInSeconds; safeSetState(() => setBuffered(bufferedTime)); } - + }; const onLoad = (data: any) => { @@ -1331,19 +1331,19 @@ const AndroidVideoPlayer: React.FC = () => { const videoDuration = data.duration; if (data.duration > 0) { setDuration(videoDuration); - + // Store the actual duration for future reference and update existing progress if (id && type) { storageService.setContentDuration(id, type, videoDuration, episodeId); storageService.updateProgressDuration(id, type, videoDuration, episodeId); - + // Update the saved duration for resume overlay if it was using an estimate if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) { setSavedDuration(videoDuration); } } } - + // Set aspect ratio from video dimensions if (data.naturalSize && data.naturalSize.width && data.naturalSize.height) { setVideoAspectRatio(data.naturalSize.width / data.naturalSize.height); @@ -1375,17 +1375,17 @@ const AndroidVideoPlayer: React.FC = () => { }); }); } - + const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => { const trackIndex = track.index !== undefined ? track.index : index; - + // Build comprehensive track name from available fields let trackName = ''; const parts = []; - + // Add language if available (try multiple possible fields) let language = track.language || track.lang || track.languageCode; - + // If no language field, try to extract from track name (e.g., "[Russian]", "[English]") if ((!language || language === 'Unknown' || language === 'und' || language === '') && track.name) { const languageMatch = track.name.match(/\[([^\]]+)\]/); @@ -1393,17 +1393,17 @@ const AndroidVideoPlayer: React.FC = () => { language = languageMatch[1].trim(); } } - + if (language && language !== 'Unknown' && language !== 'und' && language !== '') { parts.push(language.toUpperCase()); } - + // Add codec information if available (try multiple possible fields) const codec = track.type || track.codec || track.format; if (codec && codec !== 'Unknown') { parts.push(codec.toUpperCase()); } - + // Add channel information if available const channels = track.channels || track.channelCount; if (channels && channels > 0) { @@ -1419,19 +1419,19 @@ const AndroidVideoPlayer: React.FC = () => { parts.push(`${channels}CH`); } } - + // Add bitrate if available const bitrate = track.bitrate || track.bitRate; if (bitrate && bitrate > 0) { parts.push(`${Math.round(bitrate / 1000)}kbps`); } - + // Add sample rate if available const sampleRate = track.sampleRate || track.sample_rate; if (sampleRate && sampleRate > 0) { parts.push(`${Math.round(sampleRate / 1000)}kHz`); } - + // Add title if available and not generic let title = track.title || track.name || track.label; if (title && !title.match(/^(Audio|Track)\s*\d*$/i) && title !== 'Unknown') { @@ -1441,7 +1441,7 @@ const AndroidVideoPlayer: React.FC = () => { parts.push(title); } } - + // Combine parts or fallback to generic name if (parts.length > 0) { trackName = parts.join(' • '); @@ -1459,7 +1459,7 @@ const AndroidVideoPlayer: React.FC = () => { meaningfulFields.push(`${key}: ${value}`); } }); - + if (meaningfulFields.length > 0) { trackName = `Audio ${index + 1} (${meaningfulFields.slice(0, 2).join(', ')})`; } else { @@ -1467,9 +1467,9 @@ const AndroidVideoPlayer: React.FC = () => { } } } - + const trackLanguage = language || 'Unknown'; - + if (DEBUG_MODE) { logger.log(`[AndroidVideoPlayer] Processed track ${index}:`, { index: trackIndex, @@ -1482,7 +1482,7 @@ const AndroidVideoPlayer: React.FC = () => { }) }); } - + return { id: trackIndex, // Use the actual track index from react-native-video name: trackName, @@ -1490,119 +1490,119 @@ const AndroidVideoPlayer: React.FC = () => { }; }); setRnVideoAudioTracks(formattedAudioTracks); - + if (DEBUG_MODE) { logger.log(`[AndroidVideoPlayer] Formatted audio tracks:`, formattedAudioTracks); } } - // Handle text tracks - if (data.textTracks && data.textTracks.length > 0) { - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Raw text tracks data:`, data.textTracks); - data.textTracks.forEach((track: any, idx: number) => { - logger.log(`[AndroidVideoPlayer] Text Track ${idx} raw data:`, { - index: track.index, - title: track.title, - language: track.language, - type: track.type, - name: track.name, - label: track.label, - allKeys: Object.keys(track), - fullTrackObject: track - }); + // Handle text tracks + if (data.textTracks && data.textTracks.length > 0) { + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Raw text tracks data:`, data.textTracks); + data.textTracks.forEach((track: any, idx: number) => { + logger.log(`[AndroidVideoPlayer] Text Track ${idx} raw data:`, { + index: track.index, + title: track.title, + language: track.language, + type: track.type, + name: track.name, + label: track.label, + allKeys: Object.keys(track), + fullTrackObject: track }); - } - - const formattedTextTracks = data.textTracks.map((track: any, index: number) => { - const trackIndex = track.index !== undefined ? track.index : index; - - // Build comprehensive track name from available fields - let trackName = ''; - const parts = []; - - // Add language if available (try multiple possible fields) - let language = track.language || track.lang || track.languageCode; - - // If no language field, try to extract from track name (e.g., "[Russian]", "[English]") - if ((!language || language === 'Unknown' || language === 'und' || language === '') && track.title) { - const languageMatch = track.title.match(/\[([^\]]+)\]/); - if (languageMatch && languageMatch[1]) { - language = languageMatch[1].trim(); - } - } - - if (language && language !== 'Unknown' && language !== 'und' && language !== '') { - parts.push(language.toUpperCase()); - } - - // Add codec information if available (try multiple possible fields) - const codec = track.codec || track.format; - if (codec && codec !== 'Unknown' && codec !== 'und') { - parts.push(codec.toUpperCase()); - } - - // Add title if available and not generic - let title = track.title || track.name || track.label; - if (title && !title.match(/^(Subtitle|Track)\s*\d*$/i) && title !== 'Unknown') { - // Clean up title by removing language brackets and trailing punctuation - title = title.replace(/\s*\[[^\]]+\]\s*[-–—]*\s*$/, '').trim(); - if (title && title !== 'Unknown') { - parts.push(title); - } - } - - // Combine parts or fallback to generic name - if (parts.length > 0) { - trackName = parts.join(' • '); - } else { - // For simple track names like "Track 1", "Subtitle 1", etc., use them as-is - const simpleName = track.title || track.name || track.label; - if (simpleName && simpleName.match(/^(Track|Subtitle)\s*\d*$/i)) { - trackName = simpleName; - } else { - // Try to extract any meaningful info from the track object - const meaningfulFields: string[] = []; - Object.keys(track).forEach(key => { - const value = track[key]; - if (value && typeof value === 'string' && value !== 'Unknown' && value !== 'und' && value.length > 1) { - meaningfulFields.push(`${key}: ${value}`); - } - }); - - if (meaningfulFields.length > 0) { - trackName = meaningfulFields.join(' • '); - } else { - trackName = `Subtitle ${index + 1}`; - } - } - } - - return { - id: trackIndex, // Use the actual track index from react-native-video - name: trackName, - language: language, - }; }); - setRnVideoTextTracks(formattedTextTracks); - - if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Formatted text tracks:`, formattedTextTracks); - } } + const formattedTextTracks = data.textTracks.map((track: any, index: number) => { + const trackIndex = track.index !== undefined ? track.index : index; + + // Build comprehensive track name from available fields + let trackName = ''; + const parts = []; + + // Add language if available (try multiple possible fields) + let language = track.language || track.lang || track.languageCode; + + // If no language field, try to extract from track name (e.g., "[Russian]", "[English]") + if ((!language || language === 'Unknown' || language === 'und' || language === '') && track.title) { + const languageMatch = track.title.match(/\[([^\]]+)\]/); + if (languageMatch && languageMatch[1]) { + language = languageMatch[1].trim(); + } + } + + if (language && language !== 'Unknown' && language !== 'und' && language !== '') { + parts.push(language.toUpperCase()); + } + + // Add codec information if available (try multiple possible fields) + const codec = track.codec || track.format; + if (codec && codec !== 'Unknown' && codec !== 'und') { + parts.push(codec.toUpperCase()); + } + + // Add title if available and not generic + let title = track.title || track.name || track.label; + if (title && !title.match(/^(Subtitle|Track)\s*\d*$/i) && title !== 'Unknown') { + // Clean up title by removing language brackets and trailing punctuation + title = title.replace(/\s*\[[^\]]+\]\s*[-–—]*\s*$/, '').trim(); + if (title && title !== 'Unknown') { + parts.push(title); + } + } + + // Combine parts or fallback to generic name + if (parts.length > 0) { + trackName = parts.join(' • '); + } else { + // For simple track names like "Track 1", "Subtitle 1", etc., use them as-is + const simpleName = track.title || track.name || track.label; + if (simpleName && simpleName.match(/^(Track|Subtitle)\s*\d*$/i)) { + trackName = simpleName; + } else { + // Try to extract any meaningful info from the track object + const meaningfulFields: string[] = []; + Object.keys(track).forEach(key => { + const value = track[key]; + if (value && typeof value === 'string' && value !== 'Unknown' && value !== 'und' && value.length > 1) { + meaningfulFields.push(`${key}: ${value}`); + } + }); + + if (meaningfulFields.length > 0) { + trackName = meaningfulFields.join(' • '); + } else { + trackName = `Subtitle ${index + 1}`; + } + } + } + + return { + id: trackIndex, // Use the actual track index from react-native-video + name: trackName, + language: language, + }; + }); + setRnVideoTextTracks(formattedTextTracks); + + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Formatted text tracks:`, formattedTextTracks); + } + } + setIsVideoLoaded(true); setIsPlayerReady(true); - - + + // Start Trakt watching session when video loads with proper duration if (videoDuration > 0) { traktAutosync.handlePlaybackStart(currentTime, videoDuration); } - + // Complete opening animation immediately before seeking completeOpeningAnimation(); - + if (initialPosition && !isInitialSeekComplete) { logger.log(`[AndroidVideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`); // Reduced timeout from 1000ms to 500ms @@ -1616,9 +1616,9 @@ const AndroidVideoPlayer: React.FC = () => { } }, 500); } - + controlsTimeout.current = setTimeout(hideControls, 5000); - + // Auto-fetch and load English external subtitles if available if (imdbId) { fetchAvailableSubtitles(undefined, true); @@ -1738,13 +1738,13 @@ const AndroidVideoPlayer: React.FC = () => { logger.log('[AndroidVideoPlayer] Close button pressed - closing immediately and syncing to Trakt in background'); setIsSyncingBeforeClose(true); - + // Make sure we have the most accurate current time const actualCurrentTime = currentTime; const progressPercent = duration > 0 ? (actualCurrentTime / duration) * 100 : 0; - + logger.log(`[AndroidVideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`); - + // Restore Android system brightness state so app does not lock brightness const restoreSystemBrightness = async () => { if (Platform.OS !== 'android') return; @@ -1773,13 +1773,13 @@ const AndroidVideoPlayer: React.FC = () => { const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true); if (!isTablet) { setTimeout(() => { - ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {}); + ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { }); }, 50); } else { - ScreenOrientation.unlockAsync().catch(() => {}); + ScreenOrientation.unlockAsync().catch(() => { }); } disableImmersiveMode(); - + // Simple back navigation (StreamsScreen should be below Player) if ((navigation as any).canGoBack && (navigation as any).canGoBack()) { (navigation as any).goBack(); @@ -1793,13 +1793,13 @@ const AndroidVideoPlayer: React.FC = () => { const isTablet = Math.min(dw, dh) >= 768 || ((Platform as any).isPad === true); if (!isTablet) { setTimeout(() => { - ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => {}); + ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP).catch(() => { }); }, 50); } else { - ScreenOrientation.unlockAsync().catch(() => {}); + ScreenOrientation.unlockAsync().catch(() => { }); } disableImmersiveMode(); - + // Simple back navigation fallback path if ((navigation as any).canGoBack && (navigation as any).canGoBack()) { (navigation as any).goBack(); @@ -1845,7 +1845,7 @@ const AndroidVideoPlayer: React.FC = () => { clearTimeout(controlsTimeout.current); controlsTimeout.current = null; } - + setShowControls(prevShowControls => { const newShowControls = !prevShowControls; Animated.timing(fadeAnim, { @@ -1865,25 +1865,25 @@ const AndroidVideoPlayer: React.FC = () => { const handleError = (error: any) => { try { logger.error('AndroidVideoPlayer error: ', error); - + // Early return if component is unmounted to prevent iOS crashes if (!isMounted.current) { logger.warn('[AndroidVideoPlayer] Component unmounted, skipping error handling'); return; } - + // Check for codec errors that should trigger VLC fallback const errorString = JSON.stringify(error || {}); const isCodecError = errorString.includes('MediaCodecVideoRenderer error') || - errorString.includes('MediaCodecAudioRenderer error') || - errorString.includes('NO_EXCEEDS_CAPABILITIES') || - errorString.includes('NO_UNSUPPORTED_TYPE') || - errorString.includes('Decoder failed') || - errorString.includes('video/hevc') || - errorString.includes('audio/eac3') || - errorString.includes('ERROR_CODE_DECODING_FAILED') || - errorString.includes('ERROR_CODE_DECODER_INIT_FAILED'); - + errorString.includes('MediaCodecAudioRenderer error') || + errorString.includes('NO_EXCEEDS_CAPABILITIES') || + errorString.includes('NO_UNSUPPORTED_TYPE') || + errorString.includes('Decoder failed') || + errorString.includes('video/hevc') || + errorString.includes('audio/eac3') || + errorString.includes('ERROR_CODE_DECODING_FAILED') || + errorString.includes('ERROR_CODE_DECODER_INIT_FAILED'); + // If it's a codec error and we're not already using VLC, silently switch to VLC if (isCodecError && !useVLC && !vlcFallbackAttemptedRef.current) { vlcFallbackAttemptedRef.current = true; @@ -1894,7 +1894,7 @@ const AndroidVideoPlayer: React.FC = () => { errorTimeoutRef.current = null; } safeSetState(() => setShowErrorModal(false)); - + // Switch to VLC silently setTimeout(() => { if (!isMounted.current) return; @@ -1903,7 +1903,7 @@ const AndroidVideoPlayer: React.FC = () => { }, 100); return; // Do not proceed to show error UI } - + // One-shot, silent retry without showing error UI if (retryAttemptRef.current < 1) { retryAttemptRef.current = 1; @@ -1934,11 +1934,11 @@ const AndroidVideoPlayer: React.FC = () => { const isUnrecognized = !!(error?.error?.errorString && String(error.error.errorString).includes('UnrecognizedInputFormatException')); if (isUnrecognized && retryAttemptRef.current < 1) { retryAttemptRef.current = 1; - + // Check if this might be an HLS stream that needs different handling - const mightBeHls = currentStreamUrl.includes('.m3u8') || currentStreamUrl.includes('playlist') || - currentStreamUrl.includes('hls') || currentStreamUrl.includes('stream'); - + const mightBeHls = currentStreamUrl.includes('.m3u8') || currentStreamUrl.includes('playlist') || + currentStreamUrl.includes('hls') || currentStreamUrl.includes('stream'); + if (mightBeHls) { logger.warn(`[AndroidVideoPlayer] HLS stream format not recognized. Retrying with explicit HLS type and headers`); if (errorTimeoutRef.current) { @@ -1982,9 +1982,9 @@ const AndroidVideoPlayer: React.FC = () => { // Handle HLS manifest parsing errors (when content isn't actually M3U8) const isManifestParseError = error?.error?.errorCode === '23002' || - error?.errorCode === '23002' || - (error?.error?.errorString && - error.error.errorString.includes('ERROR_CODE_PARSING_MANIFEST_MALFORMED')); + error?.errorCode === '23002' || + (error?.error?.errorString && + error.error.errorString.includes('ERROR_CODE_PARSING_MANIFEST_MALFORMED')); if (isManifestParseError && retryAttemptRef.current < 2) { retryAttemptRef.current = 2; @@ -2010,10 +2010,10 @@ const AndroidVideoPlayer: React.FC = () => { // Check for specific AVFoundation server configuration errors (iOS) const isServerConfigError = error?.error?.code === -11850 || - error?.code === -11850 || - (error?.error?.localizedDescription && - error.error.localizedDescription.includes('server is not correctly configured')); - + error?.code === -11850 || + (error?.error?.localizedDescription && + error.error.localizedDescription.includes('server is not correctly configured')); + // Format error details for user display let errorMessage = 'An unknown error occurred'; if (error) { @@ -2037,18 +2037,18 @@ const AndroidVideoPlayer: React.FC = () => { } } } - + // Use safeSetState to prevent crashes on iOS when component is unmounted safeSetState(() => { setErrorDetails(errorMessage); setShowErrorModal(true); }); - + // Clear any existing timeout if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); } - + // Auto-exit only when a modal is actually visible if (showErrorModal) { errorTimeoutRef.current = setTimeout(() => { @@ -2079,7 +2079,7 @@ const AndroidVideoPlayer: React.FC = () => { // Enhanced screen lock prevention - keep screen awake as soon as player mounts const keepAwakeModuleRef = useRef(null); const keepAwakeActiveRef = useRef(false); - + useEffect(() => { try { // Use require to avoid TS dynamic import constraints @@ -2096,10 +2096,10 @@ const AndroidVideoPlayer: React.FC = () => { useEffect(() => { const mod = keepAwakeModuleRef.current; if (!mod) return; - + const activate = mod.activateKeepAwakeAsync || mod.activateKeepAwake; const deactivate = mod.deactivateKeepAwakeAsync || mod.deactivateKeepAwake; - + // Activate immediately when component mounts try { if (activate && !keepAwakeActiveRef.current) { @@ -2141,9 +2141,9 @@ const AndroidVideoPlayer: React.FC = () => { useEffect(() => { const mod = keepAwakeModuleRef.current; if (!mod) return; - + const activate = mod.activateKeepAwakeAsync || mod.activateKeepAwake; - + const handleAppStateChange = (nextAppState: string) => { if (nextAppState === 'active') { try { @@ -2161,7 +2161,7 @@ const AndroidVideoPlayer: React.FC = () => { const subscription = AppState.addEventListener('change', handleAppStateChange); return () => subscription?.remove(); }, []); - + const handleErrorExit = () => { try { // Early return if component is unmounted @@ -2169,17 +2169,17 @@ const AndroidVideoPlayer: React.FC = () => { logger.warn('[AndroidVideoPlayer] Component unmounted, skipping error exit'); return; } - + if (errorTimeoutRef.current) { clearTimeout(errorTimeoutRef.current); errorTimeoutRef.current = null; } - + // Use safeSetState to prevent crashes on iOS when component is unmounted safeSetState(() => { setShowErrorModal(false); }); - + // Add small delay before closing to ensure modal state is updated setTimeout(() => { if (isMounted.current) { @@ -2334,9 +2334,9 @@ const AndroidVideoPlayer: React.FC = () => { try { const merged = { ...(saved || {}), subtitleSize: migrated }; await storageService.saveSubtitleSettings(merged); - } catch {} + } catch { } } - try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch {} + try { await mmkvStorage.removeItem(SUBTITLE_SIZE_KEY); } catch { } return; } // If no saved settings, use responsive default @@ -2403,8 +2403,8 @@ const AndroidVideoPlayer: React.FC = () => { }); setAvailableSubtitles(stremioSubs); if (autoSelectEnglish) { - const englishSubtitle = stremioSubs.find(sub => - sub.language.toLowerCase() === 'eng' || + const englishSubtitle = stremioSubs.find(sub => + sub.language.toLowerCase() === 'eng' || sub.language.toLowerCase() === 'en' || sub.display.toLowerCase().includes('english') ); @@ -2464,7 +2464,7 @@ const AndroidVideoPlayer: React.FC = () => { logger.log(`[AndroidVideoPlayer] Fetching subtitle SRT done, size=${srtContent.length}`); const parsedCues = parseSRT(srtContent); logger.log(`[AndroidVideoPlayer] Parsed cues count=${parsedCues.length}`); - + // iOS AVPlayer workaround: clear subtitle state first, then apply if (Platform.OS === 'ios') { logger.log('[AndroidVideoPlayer] iOS detected; clearing subtitle state before apply'); @@ -2478,7 +2478,7 @@ const AndroidVideoPlayer: React.FC = () => { logger.log('[AndroidVideoPlayer] customSubtitles -> []'); setSelectedTextTrack(-1); logger.log('[AndroidVideoPlayer] selectedTextTrack -> -1'); - + // Step 2: Apply immediately (no scheduling), then do a small micro-nudge logger.log('[AndroidVideoPlayer] Applying parsed cues immediately (iOS)'); setCustomSubtitles(parsedCues); @@ -2518,7 +2518,7 @@ const AndroidVideoPlayer: React.FC = () => { const textNow = cueNow ? cueNow.text : ''; setCurrentSubtitle(textNow); logger.log('[AndroidVideoPlayer] currentSubtitle set immediately after apply (Android)'); - } catch {} + } catch { } } } catch (error) { logger.error('[AndroidVideoPlayer] Error loading Wyzie subtitle:', error); @@ -2526,7 +2526,7 @@ const AndroidVideoPlayer: React.FC = () => { logger.log('[AndroidVideoPlayer] isLoadingSubtitles -> false (error path)'); } }; - + const togglePlayback = useCallback(() => { const newPausedState = !paused; setPaused(newPausedState); @@ -2541,39 +2541,39 @@ const AndroidVideoPlayer: React.FC = () => { if (!nextEpisode || !id || isLoadingNextEpisode) return; setIsLoadingNextEpisode(true); - + try { logger.log('[AndroidVideoPlayer] Loading next episode:', nextEpisode); - + // Create episode ID for next episode using stremioId if available, otherwise construct it const nextEpisodeId = nextEpisode.stremioId || `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`; - + logger.log('[AndroidVideoPlayer] Fetching streams for next episode:', nextEpisodeId); - + // Import stremio service const stremioService = require('../../services/stremioService').default; - + let bestStream: any = null; let streamFound = false; let completedProviders = 0; const expectedProviders = new Set(); - + // Get installed addons to know how many providers to expect const installedAddons = stremioService.getInstalledAddons(); - const streamAddons = installedAddons.filter((addon: any) => + const streamAddons = installedAddons.filter((addon: any) => addon.resources && addon.resources.includes('stream') ); - + streamAddons.forEach((addon: any) => expectedProviders.add(addon.id)); - + // Collect all streams from all providers for the sources modal const allStreams: { [providerId: string]: { streams: any[]; addonName: string } } = {}; let hasNavigated = false; - + // Fetch streams for next episode await stremioService.getStreams('series', nextEpisodeId, (streams: any, addonId: any, addonName: any, error: any) => { completedProviders++; - + // Always collect streams from this provider for sources modal (even after navigation) if (streams && streams.length > 0) { allStreams[addonId] = { @@ -2581,7 +2581,7 @@ const AndroidVideoPlayer: React.FC = () => { addonName: addonName || addonId }; } - + // Navigate with first good stream found, but continue collecting streams in background if (!hasNavigated && !streamFound && streams && streams.length > 0) { // Sort streams by quality and cache status (prefer cached/debrid streams) @@ -2590,7 +2590,7 @@ const AndroidVideoPlayer: React.FC = () => { const bQuality = parseInt(b.title?.match(/(\d+)p/)?.[1] || '0', 10); const aCached = a.behaviorHints?.cached || false; const bCached = b.behaviorHints?.cached || false; - + // Prioritize cached streams first if (aCached !== bCached) { return aCached ? -1 : 1; @@ -2598,7 +2598,7 @@ const AndroidVideoPlayer: React.FC = () => { // Then sort by quality (higher quality first) return bQuality - aQuality; }); - + bestStream = sortedStreams[0]; streamFound = true; hasNavigated = true; @@ -2608,9 +2608,9 @@ const AndroidVideoPlayer: React.FC = () => { setNextLoadingProvider(addonName || addonId || null); setNextLoadingQuality(qualityText); setNextLoadingTitle(bestStream.name || bestStream.title || null); - + logger.log('[AndroidVideoPlayer] Found stream for next episode:', bestStream); - + // Pause current playback to ensure no background player remains active setPaused(true); @@ -2638,14 +2638,14 @@ const AndroidVideoPlayer: React.FC = () => { setIsLoadingNextEpisode(false); }, 100); // Small delay to ensure smooth transition } - + // If we've checked all providers and no stream found if (completedProviders >= expectedProviders.size && !streamFound) { logger.warn('[AndroidVideoPlayer] No streams found for next episode after checking all providers'); setIsLoadingNextEpisode(false); } }); - + // Fallback timeout in case providers don't respond setTimeout(() => { if (!streamFound) { @@ -2653,7 +2653,7 @@ const AndroidVideoPlayer: React.FC = () => { setIsLoadingNextEpisode(false); } }, 8000); - + } catch (error) { logger.error('[AndroidVideoPlayer] Error loading next episode:', error); setIsLoadingNextEpisode(false); @@ -2690,7 +2690,7 @@ const AndroidVideoPlayer: React.FC = () => { metadataOpacity.setValue(1); metadataScale.setValue(1); } - + Animated.parallel([ Animated.timing(pauseOverlayOpacity, { toValue: 0, @@ -2703,7 +2703,7 @@ const AndroidVideoPlayer: React.FC = () => { useNativeDriver: true, }) ]).start(() => setShowPauseOverlay(false)); - + // Show controls when overlay is touched if (!showControls) { setShowControls(true); @@ -2712,7 +2712,7 @@ const AndroidVideoPlayer: React.FC = () => { duration: 300, useNativeDriver: true, }).start(); - + // Auto-hide controls after 5 seconds if (controlsTimeout.current) { clearTimeout(controlsTimeout.current); @@ -2789,7 +2789,7 @@ const AndroidVideoPlayer: React.FC = () => { clearInterval(progressSaveInterval); setProgressSaveInterval(null); } - + // Cleanup gesture controls gestureControls.cleanup(); // Best-effort restore of Android system brightness state on unmount @@ -2807,7 +2807,7 @@ const AndroidVideoPlayer: React.FC = () => { } }; }, []); - + const safeSetState = (setter: any) => { if (isMounted.current) { setter(); @@ -2826,41 +2826,36 @@ const AndroidVideoPlayer: React.FC = () => { return; } const adjustedTime = currentTime + (subtitleOffsetSec || 0) - 0.2; - const currentCue = customSubtitles.find(cue => + const currentCue = customSubtitles.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end ); const newSubtitle = currentCue ? currentCue.text : ''; setCurrentSubtitle(newSubtitle); - + // Extract formatted segments from current cue if (currentCue?.formattedSegments) { - // Split by newlines to get per-line segments - const lines = (currentCue.text || '').split(/\r?\n/); const segmentsPerLine: SubtitleSegment[][] = []; - let segmentIndex = 0; - - for (const line of lines) { - const lineSegments: SubtitleSegment[] = []; - const words = line.split(/(\s+)/); - - for (const word of words) { - if (word.trim()) { - if (segmentIndex < currentCue.formattedSegments.length) { - lineSegments.push(currentCue.formattedSegments[segmentIndex]); - segmentIndex++; - } else { - // Fallback if segment count doesn't match - lineSegments.push({ text: word }); - } + let currentLine: SubtitleSegment[] = []; + + currentCue.formattedSegments.forEach(seg => { + const parts = seg.text.split(/\r?\n/); + parts.forEach((part, index) => { + if (index > 0) { + // New line found + segmentsPerLine.push(currentLine); + currentLine = []; } - } - - if (lineSegments.length > 0) { - segmentsPerLine.push(lineSegments); - } + if (part.length > 0) { + currentLine.push({ ...seg, text: part }); + } + }); + }); + + if (currentLine.length > 0) { + segmentsPerLine.push(currentLine); } - - setCurrentFormattedSegments(segmentsPerLine.length > 0 ? segmentsPerLine : []); + + setCurrentFormattedSegments(segmentsPerLine); } else { setCurrentFormattedSegments([]); } @@ -2914,8 +2909,8 @@ const AndroidVideoPlayer: React.FC = () => { if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier); if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec); } - } catch {} finally { - try { setSubtitleSettingsLoaded(true); } catch {} + } catch { } finally { + try { setSubtitleSettingsLoaded(true); } catch { } } })(); }, []); @@ -2976,20 +2971,20 @@ const AndroidVideoPlayer: React.FC = () => { } setShowSourcesModal(false); - + // Extract quality and provider information let newQuality = newStream.quality; if (!newQuality && newStream.title) { const qualityMatch = newStream.title.match(/(\d+)p/); newQuality = qualityMatch ? qualityMatch[0] : undefined; } - + const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown'; const newStreamName = newStream.name || newStream.title || 'Unknown Stream'; - + // Pause current playback setPaused(true); - + // Navigate with replace to reload player with new source setTimeout(() => { (navigation as any).replace('PlayerAndroid', { @@ -3031,15 +3026,15 @@ const AndroidVideoPlayer: React.FC = () => { const handleEpisodeStreamSelect = async (stream: any) => { if (!selectedEpisodeForStreams) return; - + setShowEpisodeStreamsModal(false); - + const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; const newStreamName = stream.name || stream.title || 'Unknown Stream'; - + setPaused(true); - + setTimeout(() => { (navigation as any).replace('PlayerAndroid', { uri: stream.url, @@ -3097,7 +3092,7 @@ const AndroidVideoPlayer: React.FC = () => { position: 'absolute', top: 0, left: 0, - }]}> + }]}> { height={screenDimensions.height} /> - { /> ) : ( + + + {errorDetails} + + + + + Exit Player + + + {errorDetails} + color: '#888888', + textAlign: 'center', + marginTop: 12 + }}>This dialog will auto-close in 5 seconds - - - - Exit Player - - - - This dialog will auto-close in 5 seconds - )} - + ); }; From e9a331dbd57eca42e8018b822bf2a98d72985df4 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 25 Nov 2025 01:44:34 +0530 Subject: [PATCH 07/15] ksplayer pause fix --- ios/KSPlayerView.swift | 17 ++++++++++++++++- src/components/player/KSPlayerCore.tsx | 15 +++++---------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/ios/KSPlayerView.swift b/ios/KSPlayerView.swift index cda64d56..53c58dca 100644 --- a/ios/KSPlayerView.swift +++ b/ios/KSPlayerView.swift @@ -457,9 +457,24 @@ class KSPlayerView: UIView { return } - playerView.seek(time: time) { success in + // Capture the current paused state before seeking + let wasPaused = isPaused + print("KSPlayerView: Seeking to \(time), paused state before seek: \(wasPaused)") + + playerView.seek(time: time) { [weak self] success in + guard let self = self else { return } + if success { print("KSPlayerView: Seek successful to \(time)") + + // Restore the paused state after seeking + // KSPlayer's seek may resume playback, so we need to re-apply the paused state + if wasPaused { + DispatchQueue.main.async { + self.playerView.pause() + print("KSPlayerView: Restored paused state after seek") + } + } } else { print("KSPlayerView: Seek failed to \(time)") } diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index d9cbf1b5..95a73ee7 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -849,6 +849,8 @@ const KSPlayerCore: React.FC = () => { const onPaused = () => { if (isMounted.current) { setPaused(true); + // Reset the wasPlayingBeforeDrag ref so that seeking while paused doesn't resume playback + wasPlayingBeforeDragRef.current = false; // IMMEDIATE: Send immediate pause update to Trakt when user pauses if (duration > 0) { @@ -919,8 +921,9 @@ const KSPlayerCore: React.FC = () => { if (duration > 0) { const seekTime = Math.min(value, duration - END_EPSILON); seekToTime(seekTime); - // If the video was playing before the drag, ensure we remain in playing state after the seek - if (wasPlayingBeforeDragRef.current) { + // Only resume playback if the video was playing before the drag AND is not currently paused + // This ensures that if the user paused during or before the drag, it stays paused + if (wasPlayingBeforeDragRef.current && !paused) { setTimeout(() => { if (isMounted.current) { setPaused(false); @@ -988,14 +991,6 @@ const KSPlayerCore: React.FC = () => { completeOpeningAnimation(); } - // If time is advancing right after seek and we previously intended to play, - // ensure paused state is false to keep UI in sync - if (wasPlayingBeforeDragRef.current && paused && !isDragging) { - setPaused(false); - // Reset the intent once corrected - wasPlayingBeforeDragRef.current = false; - } - // Periodic check for disabled audio track (every 3 seconds, max 3 attempts) const now = Date.now(); if (now - lastAudioTrackCheck > 3000 && !paused && duration > 0 && audioTrackFallbackAttempts < 3) { From 771765f32bf7b72d938631575cbdb4f54c8aea26 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 25 Nov 2025 01:48:50 +0530 Subject: [PATCH 08/15] apple hero drag changes --- ios/Podfile.lock | 4 +- src/components/home/AppleTVHero.tsx | 136 +++++++++++++++++----------- 2 files changed, 85 insertions(+), 55 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index bc55b40b..61b7dad7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3178,13 +3178,13 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: DisplayCriteria: - :commit: 83ba8419ca365e9397c0b45c4147755da522324e + :commit: cbc74996afb55e096bf1ff240f07d1d206ac86df :git: https://github.com/kingslay/KSPlayer.git FFmpegKit: :commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9 :git: https://github.com/kingslay/FFmpegKit.git KSPlayer: - :commit: 83ba8419ca365e9397c0b45c4147755da522324e + :commit: cbc74996afb55e096bf1ff240f07d1d206ac86df :git: https://github.com/kingslay/KSPlayer.git Libass: :commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9 diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index ffbe0929..585c7d6e 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -57,8 +57,8 @@ const STATUS_BAR_HEIGHT = StatusBar.currentHeight || 0; const HERO_HEIGHT = height * 0.85; // Animated Pagination Dot Component -const PaginationDot: React.FC<{ - isActive: boolean; +const PaginationDot: React.FC<{ + isActive: boolean; isNext: boolean; dragProgress: SharedValue; onPress: () => void; @@ -70,11 +70,11 @@ const PaginationDot: React.FC<{ const inactiveWidth = 8; const activeOpacity = 0.9; const inactiveOpacity = 0.3; - + // Calculate target width and opacity based on state let targetWidth = isActive ? activeWidth : inactiveWidth; let targetOpacity = isActive ? activeOpacity : inactiveOpacity; - + // If this is the next dot during drag, interpolate between inactive and active if (isNext && dragProgress.value > 0) { targetWidth = interpolate( @@ -90,7 +90,7 @@ const PaginationDot: React.FC<{ Extrapolation.CLAMP ); } - + // If this is the current active dot during drag, interpolate from active to inactive if (isActive && dragProgress.value > 0) { targetWidth = interpolate( @@ -106,7 +106,7 @@ const PaginationDot: React.FC<{ Extrapolation.CLAMP ); } - + return { width: withTiming(targetWidth, { duration: 300, @@ -144,11 +144,11 @@ const AppleTVHero: React.FC = ({ const insets = useSafeAreaInsets(); const { settings, updateSetting } = useSettings(); const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer(); - + // Create internal scrollY if not provided externally const internalScrollY = useSharedValue(0); const scrollY = externalScrollY || internalScrollY; - + // Determine items to display const items = useMemo(() => { if (allFeaturedContent && allFeaturedContent.length > 0) { @@ -174,10 +174,10 @@ const AppleTVHero: React.FC = ({ const [trailerPreloaded, setTrailerPreloaded] = useState(false); const [trailerShouldBePaused, setTrailerShouldBePaused] = useState(false); const trailerVideoRef = useRef(null); - + // Use ref to avoid re-fetching trailer when trailerMuted changes const showTrailersEnabled = useRef(settings?.showTrailers ?? false); - + // Update ref when showTrailers setting changes useEffect(() => { showTrailersEnabled.current = settings?.showTrailers ?? false; @@ -188,6 +188,7 @@ const AppleTVHero: React.FC = ({ // Animation values const dragProgress = useSharedValue(0); const dragDirection = useSharedValue(0); // -1 for left, 1 for right + const isDragging = useSharedValue(0); // 1 when dragging, 0 when not const logoOpacity = useSharedValue(1); const [nextIndex, setNextIndex] = useState(currentIndex); const thumbnailOpacity = useSharedValue(1); @@ -197,14 +198,14 @@ const AppleTVHero: React.FC = ({ // Animated style for trailer container - 60% height with zoom const trailerContainerStyle = useAnimatedStyle(() => { - // Fade out trailer during drag with smooth curve (inverse of next image fade) + // Faster fade out during drag - complete fade by 0.3 progress instead of 1.0 const dragFade = interpolate( dragProgress.value, - [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1], - [1, 0.95, 0.88, 0.78, 0.65, 0.5, 0.35, 0.22, 0.08, 0], + [0, 0.05, 0.1, 0.15, 0.2, 0.3], + [1, 0.85, 0.65, 0.4, 0.15, 0], Extrapolation.CLAMP ); - + return { position: 'absolute', top: 0, @@ -225,26 +226,36 @@ const AppleTVHero: React.FC = ({ }; }); - // Parallax style for background images + // Parallax style for background images - disabled during drag const backgroundParallaxStyle = useAnimatedStyle(() => { 'worklet'; const scrollYValue = scrollY.value; - + + // Disable parallax during drag to avoid transform conflicts + if (isDragging.value > 0) { + return { + transform: [ + { scale: 1.0 }, + { translateY: 0 } + ], + }; + } + // Pre-calculated constants - start at 1.0 for normal size const DEFAULT_ZOOM = 1.0; const SCROLL_UP_MULTIPLIER = 0.002; const SCROLL_DOWN_MULTIPLIER = 0.0001; const MAX_SCALE = 1.3; const PARALLAX_FACTOR = 0.3; - + // Optimized scale calculation with minimal branching const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER; const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER; const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE); - + // Single parallax calculation const parallaxOffset = scrollYValue * PARALLAX_FACTOR; - + return { transform: [ { scale }, @@ -253,26 +264,36 @@ const AppleTVHero: React.FC = ({ }; }); - // Parallax style for trailer + // Parallax style for trailer - disabled during drag const trailerParallaxStyle = useAnimatedStyle(() => { 'worklet'; const scrollYValue = scrollY.value; - + + // Disable parallax during drag to avoid transform conflicts + if (isDragging.value > 0) { + return { + transform: [ + { scale: 1.0 }, + { translateY: 0 } + ], + }; + } + // Pre-calculated constants - start at 1.0 for normal size const DEFAULT_ZOOM = 1.0; const SCROLL_UP_MULTIPLIER = 0.0015; const SCROLL_DOWN_MULTIPLIER = 0.0001; const MAX_SCALE = 1.2; const PARALLAX_FACTOR = 0.2; // Slower than background for depth - + // Optimized scale calculation with minimal branching const scrollUpScale = DEFAULT_ZOOM + Math.abs(scrollYValue) * SCROLL_UP_MULTIPLIER; const scrollDownScale = DEFAULT_ZOOM + scrollYValue * SCROLL_DOWN_MULTIPLIER; const scale = Math.min(scrollYValue < 0 ? scrollUpScale : scrollDownScale, MAX_SCALE); - + // Single parallax calculation const parallaxOffset = scrollYValue * PARALLAX_FACTOR; - + return { transform: [ { scale }, @@ -316,16 +337,16 @@ const AppleTVHero: React.FC = ({ // Pause this screen's trailer setTrailerShouldBePaused(true); setTrailerPlaying(false); - + // Fade out trailer trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); - + logger.info('[AppleTVHero] Screen lost focus - pausing trailer'); } else { // Screen gained focus - allow trailer to resume if it was ready setTrailerShouldBePaused(false); - + // If trailer was ready and loaded, restore the video opacity if (trailerReady && trailerUrl) { logger.info('[AppleTVHero] Screen gained focus - restoring trailer'); @@ -370,20 +391,20 @@ const AppleTVHero: React.FC = ({ setTrailerReady(false); setTrailerPreloaded(false); setTrailerPlaying(false); - + // Fade out any existing trailer trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); try { // Extract year from metadata - const year = currentItem.releaseInfo - ? parseInt(currentItem.releaseInfo.split('-')[0], 10) + const year = currentItem.releaseInfo + ? parseInt(currentItem.releaseInfo.split('-')[0], 10) : new Date().getFullYear(); // Extract TMDB ID if available - const tmdbId = currentItem.id?.startsWith('tmdb:') - ? currentItem.id.replace('tmdb:', '') + const tmdbId = currentItem.id?.startsWith('tmdb:') + ? currentItem.id.replace('tmdb:', '') : undefined; const contentType = currentItem.type === 'series' ? 'tv' : 'movie'; @@ -391,9 +412,9 @@ const AppleTVHero: React.FC = ({ logger.info('[AppleTVHero] Fetching trailer for:', currentItem.name, year, tmdbId); const url = await TrailerService.getTrailerUrl( - currentItem.name, - year, - tmdbId, + currentItem.name, + year, + tmdbId, contentType ); @@ -435,13 +456,13 @@ const AppleTVHero: React.FC = ({ // Handle trailer ready to play const handleTrailerReady = useCallback(() => { setTrailerReady(true); - + // Smooth crossfade: thumbnail out, trailer in thumbnailOpacity.value = withTiming(0, { duration: 800 }); trailerOpacity.value = withTiming(1, { duration: 800 }); - + logger.info('[AppleTVHero] Trailer ready - starting playback'); - + // Auto-start trailer setTrailerPlaying(true); }, [thumbnailOpacity, trailerOpacity, setTrailerPlaying]); @@ -451,11 +472,11 @@ const AppleTVHero: React.FC = ({ setTrailerError(true); setTrailerReady(false); setTrailerPlaying(false); - + // Fade back to thumbnail trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); - + logger.error('[AppleTVHero] Trailer playback error'); }, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]); @@ -463,11 +484,11 @@ const AppleTVHero: React.FC = ({ const handleTrailerEnd = useCallback(() => { logger.info('[AppleTVHero] Trailer ended'); setTrailerPlaying(false); - + // Reset trailer state setTrailerReady(false); setTrailerPreloaded(false); - + // Smooth fade back to thumbnail trailerOpacity.value = withTiming(0, { duration: 500 }); thumbnailOpacity.value = withTiming(1, { duration: 500 }); @@ -531,12 +552,12 @@ const AppleTVHero: React.FC = ({ // Instant reset - no extra fade animation dragProgress.value = 0; setNextIndex(currentIndex); - + // Immediately hide trailer and show thumbnail when index changes trailerOpacity.value = 0; thumbnailOpacity.value = 1; setTrailerPlaying(false); - + // Faster logo fade logoOpacity.value = 0; logoOpacity.value = withDelay( @@ -580,6 +601,9 @@ const AppleTVHero: React.FC = ({ .activeOffsetX([-5, 5]) // Smaller activation area - more sensitive .failOffsetY([-15, 15]) // Fail if vertical movement is detected .onStart(() => { + // Mark as dragging to disable parallax + isDragging.value = 1; + // Determine which direction and set preview runOnJS(updateInteractionTime)(); // Immediately stop trailer playback when drag starts @@ -589,10 +613,10 @@ const AppleTVHero: React.FC = ({ const translationX = event.translationX; // Use larger width multiplier for smoother visual feedback on small swipes const progress = Math.abs(translationX) / (width * 1.2); - + // Update drag progress (0 to 1) with eased curve dragProgress.value = Math.min(progress, 1); - + // Track drag direction: positive = right (previous), negative = left (next) if (translationX > 0) { dragDirection.value = 1; // Swiping right - show previous @@ -626,6 +650,9 @@ const AppleTVHero: React.FC = ({ }, (finished) => { if (finished) { + // Re-enable parallax after navigation completes + isDragging.value = withTiming(0, { duration: 200 }); + if (translationX > 0) { runOnJS(goToPrevious)(); } else { @@ -640,6 +667,9 @@ const AppleTVHero: React.FC = ({ duration: 450, easing: Easing.bezier(0.25, 0.1, 0.25, 1), // Custom ease-out for buttery smooth return }); + + // Re-enable parallax immediately on cancel + isDragging.value = withTiming(0, { duration: 200 }); } }), [goToPrevious, goToNext, updateInteractionTime, setPreviewIndex, hideTrailerOnDrag, currentIndex, items.length] @@ -654,15 +684,15 @@ const AppleTVHero: React.FC = ({ [0, 0.05, 0.12, 0.22, 0.35, 0.5, 0.65, 0.78, 0.92, 1], Extrapolation.CLAMP ); - + // Ultra-subtle slide effect with smooth ease-out curve const slideDistance = 6; // Even more subtle 6px movement const slideProgress = interpolate( dragProgress.value, [0, 0.2, 0.4, 0.6, 0.8, 1], // 6-point for ultra-smooth acceleration [ - -slideDistance * dragDirection.value, - -slideDistance * 0.8 * dragDirection.value, + -slideDistance * dragDirection.value, + -slideDistance * 0.8 * dragDirection.value, -slideDistance * 0.6 * dragDirection.value, -slideDistance * 0.35 * dragDirection.value, -slideDistance * 0.12 * dragDirection.value, @@ -670,7 +700,7 @@ const AppleTVHero: React.FC = ({ ], Extrapolation.CLAMP ); - + return { opacity, transform: [{ translateX: slideProgress }], @@ -685,7 +715,7 @@ const AppleTVHero: React.FC = ({ [1, 0.5, 0], Extrapolation.CLAMP ); - + return { opacity: dragFade * logoOpacity.value, }; @@ -915,10 +945,10 @@ const AppleTVHero: React.FC = ({ style={logoAnimatedStyle} > {currentItem.logo && !logoError[currentIndex] ? ( - Date: Tue, 25 Nov 2025 01:57:50 +0530 Subject: [PATCH 09/15] simplified thisweeeksection cards --- src/components/home/ThisWeekSection.tsx | 284 ++++++++++++------------ 1 file changed, 137 insertions(+), 147 deletions(-) diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index f9a53a6a..3146f072 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -60,7 +60,7 @@ export const ThisWeekSection = React.memo(() => { // Enhanced responsive sizing for tablets and TV screens const deviceWidth = Dimensions.get('window').width; const deviceHeight = Dimensions.get('window').height; - + // Determine device type based on width const getDeviceType = useCallback(() => { if (deviceWidth >= BREAKPOINTS.tv) return 'tv'; @@ -68,13 +68,13 @@ export const ThisWeekSection = React.memo(() => { if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet'; return 'phone'; }, [deviceWidth]); - + const deviceType = getDeviceType(); const isTablet = deviceType === 'tablet'; const isLargeTablet = deviceType === 'largeTablet'; const isTV = deviceType === 'tv'; const isLargeScreen = isTablet || isLargeTablet || isTV; - + // Enhanced responsive sizing const computedItemWidth = useMemo(() => { switch (deviceType) { @@ -88,7 +88,7 @@ export const ThisWeekSection = React.memo(() => { return ITEM_WIDTH; // phone } }, [deviceType, deviceWidth]); - + const computedItemHeight = useMemo(() => { switch (deviceType) { case 'tv': @@ -101,7 +101,7 @@ export const ThisWeekSection = React.memo(() => { return ITEM_HEIGHT; // phone } }, [deviceType]); - + // Enhanced spacing and padding const horizontalPadding = useMemo(() => { switch (deviceType) { @@ -115,7 +115,7 @@ export const ThisWeekSection = React.memo(() => { return 16; // phone } }, [deviceType]); - + const itemSpacing = useMemo(() => { switch (deviceType) { case 'tv': @@ -136,13 +136,13 @@ export const ThisWeekSection = React.memo(() => { // Limit episodes to prevent memory issues and add release status const episodes = memoryManager.limitArraySize(thisWeekSection.data, 20); // Limit to 20 for home screen - + return episodes.map(episode => ({ ...episode, isReleased: episode.releaseDate ? isBefore(parseISO(episode.releaseDate), new Date()) : false, })); }, [calendarData]); - + const handleEpisodePress = (episode: ThisWeekEpisode) => { // For upcoming episodes, go to the metadata screen if (!episode.isReleased) { @@ -154,7 +154,7 @@ export const ThisWeekSection = React.memo(() => { }); return; } - + // For released episodes, go to the streams screen const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`; navigation.navigate('Streams', { @@ -163,136 +163,114 @@ export const ThisWeekSection = React.memo(() => { episodeId }); }; - + const handleViewAll = () => { navigation.navigate('Calendar' as any); }; - + if (thisWeekEpisodes.length === 0) { return null; } - + const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => { // Handle episodes without release dates gracefully const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null; - const formattedDate = releaseDate ? format(releaseDate, 'E, MMM d') : 'TBA'; + const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : 'TBA'; const isReleased = item.isReleased; - + // Use episode still image if available, fallback to series poster - const imageUrl = item.still_path ? - tmdbService.getImageUrl(item.still_path) : - (item.season_poster_path ? - tmdbService.getImageUrl(item.season_poster_path) : + const imageUrl = item.still_path ? + tmdbService.getImageUrl(item.still_path) : + (item.season_poster_path ? + tmdbService.getImageUrl(item.season_poster_path) : item.poster); - + return ( handleEpisodePress(item)} - activeOpacity={0.8} + activeOpacity={0.7} > - - - {/* Enhanced gradient overlay */} - + + - {/* Content area */} + + + + {isReleased ? 'New' : formattedDate} + + + + {item.seriesName} - - - {item.title} - - - {item.overview && ( + + - {item.overview} - - )} - - - - S{item.season}:E{item.episode} • - - - - {formattedDate} + S{item.season} E{item.episode} + + + + {item.title} - - + + ); }; - + return ( { > - This Week + This Week { } ]}> View All - - + item.id} @@ -345,10 +323,10 @@ export const ThisWeekSection = React.memo(() => { horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={[ - styles.listContent, - { - paddingLeft: horizontalPadding, - paddingRight: horizontalPadding + styles.listContent, + { + paddingLeft: horizontalPadding, + paddingRight: horizontalPadding } ]} snapToInterval={computedItemWidth + itemSpacing} @@ -371,7 +349,7 @@ export const ThisWeekSection = React.memo(() => { const styles = StyleSheet.create({ container: { - marginVertical: 20, + marginVertical: 24, }, header: { flexDirection: 'row', @@ -400,14 +378,15 @@ const styles = StyleSheet.create({ viewAllButton: { flexDirection: 'row', alignItems: 'center', - paddingVertical: 8, - paddingHorizontal: 10, + paddingVertical: 6, + paddingHorizontal: 12, borderRadius: 20, - backgroundColor: 'rgba(255,255,255,0.1)', - marginRight: -10, + backgroundColor: 'rgba(255,255,255,0.08)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.05)', }, viewAllText: { - fontSize: 14, + fontSize: 13, fontWeight: '600', marginRight: 4, }, @@ -432,10 +411,11 @@ const styles = StyleSheet.create({ height: '100%', borderRadius: 16, overflow: 'hidden', - shadowOffset: { width: 0, height: 8 }, + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, - shadowRadius: 12, - elevation: 12, + shadowRadius: 8, + elevation: 8, }, imageContainer: { width: '100%', @@ -453,44 +433,54 @@ const styles = StyleSheet.create({ right: 0, top: 0, bottom: 0, - justifyContent: 'flex-end', + justifyContent: 'space-between', padding: 12, borderRadius: 16, }, + cardHeader: { + flexDirection: 'row', + justifyContent: 'flex-end', + width: '100%', + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + overflow: 'hidden', + }, + statusText: { + color: '#fff', + fontSize: 10, + fontWeight: '700', + textTransform: 'uppercase', + }, contentArea: { width: '100%', }, seriesName: { fontSize: 16, - fontWeight: '700', - marginBottom: 6, - }, - episodeTitle: { - fontSize: 14, - fontWeight: '600', + fontWeight: '800', marginBottom: 4, - lineHeight: 18, + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, }, - overview: { - fontSize: 12, - lineHeight: 16, - marginBottom: 6, - opacity: 0.9, - }, - dateContainer: { + metaContainer: { flexDirection: 'row', alignItems: 'center', - marginTop: 4, }, - episodeInfo: { + seasonBadge: { fontSize: 12, - fontWeight: '600', - marginRight: 4, + fontWeight: '700', }, - releaseDate: { - fontSize: 13, - fontWeight: '600', - marginLeft: 6, - letterSpacing: 0.3, + dotSeparator: { + marginHorizontal: 6, + fontSize: 12, + color: 'rgba(255,255,255,0.5)', + }, + episodeTitle: { + fontSize: 12, + fontWeight: '500', + flex: 1, }, }); \ No newline at end of file From 2494d45e8f54731621201123348b0afe12b67504 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 25 Nov 2025 02:05:11 +0530 Subject: [PATCH 10/15] slight Ui changes this week section --- src/components/home/ThisWeekSection.tsx | 95 ++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 8 deletions(-) diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index 3146f072..fbace08a 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -50,6 +50,10 @@ interface ThisWeekEpisode { vote_average: number; still_path: string | null; season_poster_path: string | null; + // Grouping fields + isGroup?: boolean; + episodeCount?: number; + episodeRange?: string; } export const ThisWeekSection = React.memo(() => { @@ -134,16 +138,72 @@ export const ThisWeekSection = React.memo(() => { const thisWeekSection = calendarData.find(section => section.title === 'This Week'); if (!thisWeekSection) return []; - // Limit episodes to prevent memory issues and add release status - const episodes = memoryManager.limitArraySize(thisWeekSection.data, 20); // Limit to 20 for home screen + // Get raw episodes (limit to 60 to be safe for performance but allow grouping) + const rawEpisodes = memoryManager.limitArraySize(thisWeekSection.data, 60); - return episodes.map(episode => ({ - ...episode, - isReleased: episode.releaseDate ? isBefore(parseISO(episode.releaseDate), new Date()) : false, - })); + // Group by series and date + const groups: Record = {}; + + rawEpisodes.forEach(ep => { + // Create a unique key for series + date + const dateKey = ep.releaseDate || 'unknown'; + const key = `${ep.seriesId}_${dateKey}`; + + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(ep); + }); + + const processedItems: ThisWeekEpisode[] = []; + + Object.values(groups).forEach(group => { + // Sort episodes in the group by episode number + group.sort((a, b) => a.episode - b.episode); + + const firstEp = group[0]; + const isReleased = firstEp.releaseDate ? isBefore(parseISO(firstEp.releaseDate), new Date()) : false; + + if (group.length === 1) { + processedItems.push({ + ...firstEp, + isReleased + }); + } else { + // Create group item + const lastEp = group[group.length - 1]; + processedItems.push({ + ...firstEp, + id: `group_${firstEp.seriesId}_${firstEp.releaseDate}`, // Unique ID for the group + title: `${group.length} New Episodes`, + isReleased, + isGroup: true, + episodeCount: group.length, + episodeRange: `E${firstEp.episode}-${lastEp.episode}` + }); + } + }); + + // Sort by release date + processedItems.sort((a, b) => { + if (!a.releaseDate) return 1; + if (!b.releaseDate) return -1; + return a.releaseDate.localeCompare(b.releaseDate); + }); + + return memoryManager.limitArraySize(processedItems, 20); }, [calendarData]); const handleEpisodePress = (episode: ThisWeekEpisode) => { + // For grouped episodes, always go to series details + if (episode.isGroup) { + navigation.navigate('Metadata', { + id: episode.seriesId, + type: 'series' + }); + return; + } + // For upcoming episodes, go to the metadata screen if (!episode.isReleased) { const episodeId = `${episode.seriesId}:${episode.season}:${episode.episode}`; @@ -187,6 +247,15 @@ export const ThisWeekSection = React.memo(() => { return ( + {item.isGroup && ( + + )} { { backgroundColor: isReleased ? currentTheme.colors.primary : 'rgba(0,0,0,0.6)' } ]}> - {isReleased ? 'New' : formattedDate} + {isReleased ? (item.isGroup ? 'Released' : 'New') : formattedDate} @@ -250,7 +319,7 @@ export const ThisWeekSection = React.memo(() => { fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 12 } ]}> - S{item.season} E{item.episode} + S{item.season} {item.isGroup ? item.episodeRange : `E${item.episode}`} Date: Tue, 25 Nov 2025 02:10:29 +0530 Subject: [PATCH 11/15] downloads notif fix --- src/services/notificationService.ts | 141 ++++++++++++++++------------ temp_libtorrent | 1 - 2 files changed, 81 insertions(+), 61 deletions(-) delete mode 160000 temp_libtorrent diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index d1c385a1..fa9ee950 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -56,7 +56,9 @@ class NotificationService { private appStateSubscription: any = null; private lastSyncTime: number = 0; private readonly MIN_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes minimum between syncs - + // Download notification tracking - stores progress value (50) when notified + private lastDownloadNotificationTime: Map = new Map(); + private constructor() { // Initialize notifications this.configureNotifications(); @@ -88,7 +90,7 @@ class NotificationService { // Request permissions if needed const { status: existingStatus } = await Notifications.getPermissionsAsync(); - + if (existingStatus !== 'granted') { const { status } = await Notifications.requestPermissionsAsync(); if (status !== 'granted') { @@ -102,7 +104,7 @@ class NotificationService { private async loadSettings(): Promise { try { const storedSettings = await mmkvStorage.getItem(NOTIFICATION_SETTINGS_KEY); - + if (storedSettings) { this.settings = { ...DEFAULT_NOTIFICATION_SETTINGS, ...JSON.parse(storedSettings) }; } @@ -122,7 +124,7 @@ class NotificationService { private async loadScheduledNotifications(): Promise { try { const storedNotifications = await mmkvStorage.getItem(NOTIFICATION_STORAGE_KEY); - + if (storedNotifications) { this.scheduledNotifications = JSON.parse(storedNotifications); } @@ -156,9 +158,9 @@ class NotificationService { // Check if notification already exists for this episode const existingNotification = this.scheduledNotifications.find( - notification => notification.seriesId === item.seriesId && - notification.season === item.season && - notification.episode === item.episode + notification => notification.seriesId === item.seriesId && + notification.season === item.season && + notification.episode === item.episode ); if (existingNotification) { return null; // Don't schedule duplicate notifications @@ -166,22 +168,22 @@ class NotificationService { const releaseDate = parseISO(item.releaseDate); const now = new Date(); - + // If release date has already passed, don't schedule if (releaseDate < now) { return null; } - + try { // Calculate notification time (default to 24h before air time) const notificationTime = new Date(releaseDate); notificationTime.setHours(notificationTime.getHours() - this.settings.timeBeforeAiring); - + // If notification time has already passed, don't schedule the notification if (notificationTime < now) { return null; } - + // Schedule the notification const notificationId = await Notifications.scheduleNotificationAsync({ content: { @@ -197,16 +199,16 @@ class NotificationService { type: SchedulableTriggerInputTypes.DATE, }, }); - + // Add to scheduled notifications this.scheduledNotifications.push({ ...item, notified: false, }); - + // Save to storage await this.saveScheduledNotifications(); - + return notificationId; } catch (error) { logger.error('Error scheduling notification:', error); @@ -218,16 +220,16 @@ class NotificationService { if (!this.settings.enabled) { return 0; } - + let scheduledCount = 0; - + for (const item of items) { const notificationId = await this.scheduleEpisodeNotification(item); if (notificationId) { scheduledCount++; } } - + return scheduledCount; } @@ -235,12 +237,12 @@ class NotificationService { try { // Cancel with Expo await Notifications.cancelScheduledNotificationAsync(id); - + // Remove from our tracked notifications this.scheduledNotifications = this.scheduledNotifications.filter( notification => notification.id !== id ); - + // Save updated list await this.saveScheduledNotifications(); } catch (error) { @@ -268,10 +270,10 @@ class NotificationService { // Subscribe to library updates from catalog service this.librarySubscription = catalogService.subscribeToLibraryUpdates(async (libraryItems) => { if (!this.settings.enabled) return; - + const now = Date.now(); const timeSinceLastSync = now - this.lastSyncTime; - + // Only sync if enough time has passed since last sync if (timeSinceLastSync >= this.MIN_SYNC_INTERVAL) { // Reduced logging verbosity @@ -309,7 +311,7 @@ class NotificationService { if (nextAppState === 'active' && this.settings.enabled) { const now = Date.now(); const timeSinceLastSync = now - this.lastSyncTime; - + // Only sync if enough time has passed since last sync if (timeSinceLastSync >= this.MIN_SYNC_INTERVAL) { // App came to foreground, sync notifications @@ -327,6 +329,21 @@ class NotificationService { try { if (!this.settings.enabled) return; if (AppState.currentState === 'active') return; + + // Only notify at 50% progress + if (progress < 50) { + return; // Skip notifications before 50% + } + + // Check if we've already notified at 50% for this download + const lastNotifiedProgress = this.lastDownloadNotificationTime.get(title) || 0; + if (lastNotifiedProgress >= 50) { + return; // Already notified at 50%, don't notify again + } + + // Mark that we've notified at 50% + this.lastDownloadNotificationTime.set(title, 50); + const downloadedMb = Math.floor((downloadedBytes || 0) / (1024 * 1024)); const totalMb = totalBytes ? Math.floor(totalBytes / (1024 * 1024)) : undefined; const body = `${progress}%` + (totalMb !== undefined ? ` • ${downloadedMb}MB / ${totalMb}MB` : ''); @@ -348,6 +365,7 @@ class NotificationService { try { if (!this.settings.enabled) return; if (AppState.currentState === 'active') return; + await Notifications.scheduleNotificationAsync({ content: { title: 'Download complete', @@ -356,6 +374,9 @@ class NotificationService { }, trigger: null, }); + + // Clean up tracking entry after completion to prevent memory leaks + this.lastDownloadNotificationTime.delete(title); } catch (error) { logger.error('[NotificationService] notifyDownloadComplete error:', error); } @@ -365,14 +386,14 @@ class NotificationService { private async syncNotificationsForLibrary(libraryItems: any[]): Promise { try { const seriesItems = libraryItems.filter(item => item.type === 'series'); - + // Limit series to prevent memory overflow during notifications sync const limitedSeries = memoryManager.limitArraySize(seriesItems, 50); - + if (limitedSeries.length < seriesItems.length) { logger.warn(`[NotificationService] Limited series sync from ${seriesItems.length} to ${limitedSeries.length} to prevent memory issues`); } - + // Process in small batches with memory management await memoryManager.processArrayInBatches( limitedSeries, @@ -386,10 +407,10 @@ class NotificationService { 3, // Very small batch size to prevent memory spikes 800 // Longer delay to prevent API overwhelming and reduce heating ); - + // Force cleanup after processing memoryManager.forceGarbageCollection(); - + // Reduced logging verbosity // logger.log(`[NotificationService] Synced notifications for ${limitedSeries.length} series from library`); } catch (error) { @@ -402,20 +423,20 @@ class NotificationService { try { // Update last sync time at the start this.lastSyncTime = Date.now(); - + // Reduced logging verbosity // logger.log('[NotificationService] Starting comprehensive background sync'); - + // Get library items const libraryItems = await catalogService.getLibraryItems(); await this.syncNotificationsForLibrary(libraryItems); - + // Sync Trakt items if authenticated await this.syncTraktNotifications(); - + // Clean up old notifications await this.cleanupOldNotifications(); - + // Reduced logging verbosity // logger.log('[NotificationService] Background sync completed'); } catch (error) { @@ -435,7 +456,7 @@ class NotificationService { // Reduced logging verbosity // logger.log('[NotificationService] Syncing comprehensive Trakt notifications'); - + // Get all Trakt data sources (same as calendar screen uses) const [watchlistShows, continueWatching, watchedShows, collectionShows] = await Promise.all([ traktService.getWatchlistShows(), @@ -446,7 +467,7 @@ class NotificationService { // Combine and deduplicate shows using the same logic as calendar screen const allTraktShows = new Map(); - + // Add watchlist shows if (watchlistShows) { watchlistShows.forEach((item: any) => { @@ -523,11 +544,11 @@ class NotificationService { // Sync notifications for each Trakt show using memory-efficient batching const traktShows = Array.from(allTraktShows.values()); const limitedTraktShows = memoryManager.limitArraySize(traktShows, 30); // Limit Trakt shows - + if (limitedTraktShows.length < traktShows.length) { logger.warn(`[NotificationService] Limited Trakt shows sync from ${traktShows.length} to ${limitedTraktShows.length} to prevent memory issues`); } - + let syncedCount = 0; await memoryManager.processArrayInBatches( limitedTraktShows, @@ -542,7 +563,7 @@ class NotificationService { 2, // Even smaller batch size for Trakt shows 1000 // Longer delay to prevent API rate limiting ); - + // Clear Trakt shows array to free memory memoryManager.clearObjects(traktShows, limitedTraktShows); @@ -558,23 +579,23 @@ class NotificationService { try { // Check memory pressure before processing memoryManager.checkMemoryPressure(); - + // Use the new memory-efficient method to fetch only upcoming episodes const episodeData = await stremioService.getUpcomingEpisodes('series', seriesId, { daysBack: 7, // 1 week back for notifications daysAhead: 28, // 4 weeks ahead for notifications maxEpisodes: 10, // Limit to 10 episodes per series for notifications }); - + let upcomingEpisodes: any[] = []; let metadata: any = null; - + if (episodeData && episodeData.episodes.length > 0) { metadata = { name: episodeData.seriesName, poster: episodeData.poster, }; - + upcomingEpisodes = episodeData.episodes .filter(video => { if (!video.released) return false; @@ -612,7 +633,7 @@ class NotificationService { // Get upcoming episodes from TMDB const now = new Date(); const fourWeeksLater = addDays(now, 28); - + // Check current and next seasons for upcoming episodes for (let seasonNum = tmdbDetails.number_of_seasons; seasonNum >= Math.max(1, tmdbDetails.number_of_seasons - 2); seasonNum--) { try { @@ -641,11 +662,11 @@ class NotificationService { logger.warn(`[NotificationService] TMDB fallback failed for ${seriesId}:`, tmdbError); } } - + if (!metadata) { return; } - + // Cancel existing notifications for this series const existingNotifications = await Notifications.getAllScheduledNotificationsAsync(); for (const notification of existingNotifications) { @@ -653,17 +674,17 @@ class NotificationService { await Notifications.cancelScheduledNotificationAsync(notification.identifier); } } - + // Remove from our tracked notifications this.scheduledNotifications = this.scheduledNotifications.filter( notification => notification.seriesId !== seriesId ); - + // Schedule new notifications for upcoming episodes with memory limits if (upcomingEpisodes.length > 0 && metadata) { // Limit notifications per series to prevent memory overflow const limitedEpisodes = memoryManager.limitArraySize(upcomingEpisodes, 5); - + const notificationItems: NotificationItem[] = limitedEpisodes.map(episode => ({ id: episode.id, seriesId, @@ -675,23 +696,23 @@ class NotificationService { notified: false, poster: metadata.poster, })); - + const scheduledCount = await this.scheduleMultipleEpisodeNotifications(notificationItems); - + // Clear notification items array to free memory memoryManager.clearObjects(notificationItems, upcomingEpisodes); - + // Reduced logging verbosity // logger.log(`[NotificationService] Scheduled ${scheduledCount} notifications for ${metadata.name}`); } else { // logger.log(`[NotificationService] No upcoming episodes found for ${metadata?.name || seriesId}`); } - + // Clear episode data to free memory if (episodeData) { memoryManager.clearObjects(episodeData.episodes); } - + } catch (error) { logger.error(`[NotificationService] Error updating notifications for series ${seriesId}:`, error); } finally { @@ -705,18 +726,18 @@ class NotificationService { try { const now = new Date(); const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); - + // Remove notifications for episodes that have already aired const validNotifications = this.scheduledNotifications.filter(notification => { const releaseDate = parseISO(notification.releaseDate); return releaseDate > oneDayAgo; }); - + if (validNotifications.length !== this.scheduledNotifications.length) { this.scheduledNotifications = validNotifications; await this.saveScheduledNotifications(); // Reduced logging verbosity - // logger.log(`[NotificationService] Cleaned up ${this.scheduledNotifications.length - validNotifications.length} old notifications`); + // logger.log(`[NotificationService] Cleaned up ${this.scheduledNotifications.length - validNotifications.length} old notifications`); } } catch (error) { logger.error('[NotificationService] Error cleaning up notifications:', error); @@ -734,17 +755,17 @@ class NotificationService { public getNotificationStats(): { total: number; upcoming: number; thisWeek: number } { const now = new Date(); const oneWeekLater = addDays(now, 7); - + const upcoming = this.scheduledNotifications.filter(notification => { const releaseDate = parseISO(notification.releaseDate); return releaseDate > now; }); - + const thisWeek = upcoming.filter(notification => { const releaseDate = parseISO(notification.releaseDate); return releaseDate < oneWeekLater; }); - + return { total: this.scheduledNotifications.length, upcoming: upcoming.length, @@ -758,12 +779,12 @@ class NotificationService { clearInterval(this.backgroundSyncInterval); this.backgroundSyncInterval = null; } - + if (this.librarySubscription) { this.librarySubscription(); this.librarySubscription = null; } - + if (this.appStateSubscription) { this.appStateSubscription.remove(); this.appStateSubscription = null; diff --git a/temp_libtorrent b/temp_libtorrent deleted file mode 160000 index b22f2a38..00000000 --- a/temp_libtorrent +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b22f2a386d86fbb31a5f60af62153e9ce77390a5 From 03aa45a0b0a0d931d0923a1ffe91ae67f6de8717 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 25 Nov 2025 02:31:48 +0530 Subject: [PATCH 12/15] 1.2.10 25 --- android/app/build.gradle | 6 +- android/app/src/main/res/values/strings.xml | 2 +- app.json | 8 +- ios/Nuvio/Info.plist | 198 ++++++++++---------- ios/Nuvio/NuvioRelease.entitlements | 9 +- ios/Nuvio/Supporting/Expo.plist | 2 +- nuvio-source.json | 8 + src/utils/version.ts | 2 +- 8 files changed, 118 insertions(+), 117 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 0d17fb41..4bb7b500 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -95,8 +95,8 @@ android { applicationId 'com.nuvio.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 24 - versionName "1.2.9" + versionCode 25 + versionName "1.2.10" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } @@ -118,7 +118,7 @@ android { def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4] applicationVariants.all { variant -> variant.outputs.each { output -> - def baseVersionCode = 24 // Current versionCode 24 from defaultConfig + def baseVersionCode = 25 // Current versionCode 25 from defaultConfig def abiName = output.getFilter(com.android.build.OutputFile.ABI) def versionCode = baseVersionCode * 100 // Base multiplier diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 97608d4c..a76a5351 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,5 +3,5 @@ contain false dark - 1.2.9 + 1.2.10 \ No newline at end of file diff --git a/app.json b/app.json index 434a415f..dfa4d309 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Nuvio", "slug": "nuvio", - "version": "1.2.9", + "version": "1.2.10", "orientation": "default", "backgroundColor": "#020404", "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", @@ -18,7 +18,7 @@ "supportsTablet": true, "requireFullScreen": true, "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", - "buildNumber": "24", + "buildNumber": "25", "infoPlist": { "NSAppTransportSecurity": { "NSAllowsArbitraryLoads": true @@ -52,7 +52,7 @@ "WRITE_SETTINGS" ], "package": "com.nuvio.app", - "versionCode": 24, + "versionCode": 25, "architectures": [ "arm64-v8a", "armeabi-v7a", @@ -105,6 +105,6 @@ "fallbackToCacheTimeout": 30000, "url": "https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest" }, - "runtimeVersion": "1.2.9" + "runtimeVersion": "1.2.10" } } diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist index 3ffb2941..d3e923d0 100644 --- a/ios/Nuvio/Info.plist +++ b/ios/Nuvio/Info.plist @@ -1,103 +1,101 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Nuvio - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.2.9 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - nuvio - com.nuvio.app - - - - CFBundleURLSchemes - - exp+nuvio - - - - CFBundleVersion - 24 - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSBonjourServices - - _http._tcp - _googlecast._tcp - _CC1AD845._googlecast._tcp - - NSLocalNetworkUsageDescription - Allow $(PRODUCT_NAME) to access your local network - NSMicrophoneUsageDescription - This app does not require microphone access. - RCTNewArchEnabled - - RCTRootViewBackgroundColor - 4278322180 - UIBackgroundModes - - audio - - UIFileSharingEnabled - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Dark - UIViewControllerBasedStatusBarAppearance - - - \ No newline at end of file + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nuvio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.10 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nuvio + com.nuvio.app + + + + CFBundleURLSchemes + + exp+nuvio + + + + CFBundleVersion + 25 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _http._tcp + _googlecast._tcp + _CC1AD845._googlecast._tcp + + NSLocalNetworkUsageDescription + Allow $(PRODUCT_NAME) to access your local network + RCTNewArchEnabled + + RCTRootViewBackgroundColor + 4278322180 + UIBackgroundModes + + audio + + UIFileSharingEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Nuvio/NuvioRelease.entitlements b/ios/Nuvio/NuvioRelease.entitlements index a0bc443f..0c67376e 100644 --- a/ios/Nuvio/NuvioRelease.entitlements +++ b/ios/Nuvio/NuvioRelease.entitlements @@ -1,10 +1,5 @@ - - aps-environment - development - com.apple.developer.associated-domains - - - \ No newline at end of file + + diff --git a/ios/Nuvio/Supporting/Expo.plist b/ios/Nuvio/Supporting/Expo.plist index ce214f74..c7cf5f80 100644 --- a/ios/Nuvio/Supporting/Expo.plist +++ b/ios/Nuvio/Supporting/Expo.plist @@ -9,7 +9,7 @@ EXUpdatesLaunchWaitMs 30000 EXUpdatesRuntimeVersion - 1.2.9 + 1.2.10 EXUpdatesURL https://grim-reyna-tapframe-69970143.koyeb.app/api/manifest diff --git a/nuvio-source.json b/nuvio-source.json index 779bae75..49bda03b 100644 --- a/nuvio-source.json +++ b/nuvio-source.json @@ -30,6 +30,14 @@ "https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true" ], "versions": [ + { + "version": "1.2.10", + "buildVersion": "25", + "date": "2025-11-25", + "localizedDescription": "# Nuvio Media Hub – v1.2.10 \n\n## Update Notes\n- **Dependency updates** for stability and performance \n- **Trakt optimizations** for smoother syncing \n- **Subtitle RTL detection** improvements for better language handling \n- **KSPlayer** pause behavior improvements \n- Fixed incorrect **HDR detection logic** in KSPlayer \n- Simplified **This Week’s section** card UI for a cleaner look \n\n## 📦 Changelog & Download\n[View on GitHub](https://github.com/tapframe/NuvioStreaming/releases/tag/1.2.10)\n\n🌐 **Official Website:** [tapframe.github.io/NuvioStreaming](https://tapframe.github.io/NuvioStreaming)\n\nIf you like **Nuvio Media Hub**, please consider **⭐ starring it on GitHub**. It really helps the project grow \n[⭐ Star on GitHub](https://github.com/tapframe/NuvioStreaming)", + "downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.2.10/Stable_1-2-10.ipa", + "size": 25700000 + }, { "version": "1.2.9", "buildVersion": "24", diff --git a/src/utils/version.ts b/src/utils/version.ts index 84af0b65..5a958df0 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,7 +1,7 @@ // Single source of truth for the app version displayed in Settings // Update this when bumping app version -export const APP_VERSION = '1.2.9'; +export const APP_VERSION = '1.2.10'; export function getDisplayedAppVersion(): string { return APP_VERSION; From bbf035ebae058b3eef00197bf57e8e23922f0390 Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 26 Nov 2025 00:15:32 +0530 Subject: [PATCH 13/15] changes --- ios/Nuvio.xcodeproj/project.pbxproj | 6 +- ios/Nuvio/Info.plist | 198 ++++++++++++++-------------- ios/Nuvio/NuvioRelease.entitlements | 9 +- package-lock.json | 2 +- package.json | 2 +- 5 files changed, 112 insertions(+), 105 deletions(-) diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index 291a8022..38083639 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -477,7 +477,7 @@ ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = Nuvio; + PRODUCT_NAME = "Nuvio"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -508,8 +508,8 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; - PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; - PRODUCT_NAME = Nuvio; + PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app"; + PRODUCT_NAME = "Nuvio"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist index d3e923d0..14731e58 100644 --- a/ios/Nuvio/Info.plist +++ b/ios/Nuvio/Info.plist @@ -1,101 +1,103 @@ - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Nuvio - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.2.10 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleURLSchemes - - nuvio - com.nuvio.app - - - - CFBundleURLSchemes - - exp+nuvio - - - - CFBundleVersion - 25 - LSMinimumSystemVersion - 12.0 - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSBonjourServices - - _http._tcp - _googlecast._tcp - _CC1AD845._googlecast._tcp - - NSLocalNetworkUsageDescription - Allow $(PRODUCT_NAME) to access your local network - RCTNewArchEnabled - - RCTRootViewBackgroundColor - 4278322180 - UIBackgroundModes - - audio - - UIFileSharingEnabled - - UILaunchStoryboardName - SplashScreen - UIRequiredDeviceCapabilities - - arm64 - - UIRequiresFullScreen - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIUserInterfaceStyle - Dark - UIViewControllerBasedStatusBarAppearance - - - + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Nuvio + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.2.10 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + nuvio + com.nuvio.app + + + + CFBundleURLSchemes + + exp+nuvio + + + + CFBundleVersion + 25 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSBonjourServices + + _http._tcp + _googlecast._tcp + _CC1AD845._googlecast._tcp + + NSLocalNetworkUsageDescription + Allow $(PRODUCT_NAME) to access your local network + NSMicrophoneUsageDescription + This app does not require microphone access. + RCTNewArchEnabled + + RCTRootViewBackgroundColor + 4278322180 + UIBackgroundModes + + audio + + UIFileSharingEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Dark + UIViewControllerBasedStatusBarAppearance + + + \ No newline at end of file diff --git a/ios/Nuvio/NuvioRelease.entitlements b/ios/Nuvio/NuvioRelease.entitlements index 0c67376e..a0bc443f 100644 --- a/ios/Nuvio/NuvioRelease.entitlements +++ b/ios/Nuvio/NuvioRelease.entitlements @@ -1,5 +1,10 @@ - - + + aps-environment + development + com.apple.developer.associated-domains + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8bb04f90..665e72e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@adrianso/react-native-device-brightness": "^1.2.7", "@backpackapp-io/react-native-toast": "^0.15.1", "@bottom-tabs/react-navigation": "^1.0.2", - "@d11/react-native-fast-image": "^8.8.0", + "@d11/react-native-fast-image": "^8.13.0", "@expo/env": "^2.0.7", "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", diff --git a/package.json b/package.json index 9e928b78..0ec45496 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@adrianso/react-native-device-brightness": "^1.2.7", "@backpackapp-io/react-native-toast": "^0.15.1", "@bottom-tabs/react-navigation": "^1.0.2", - "@d11/react-native-fast-image": "^8.8.0", + "@d11/react-native-fast-image": "^8.13.0", "@expo/env": "^2.0.7", "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.2", From 6d1ba14ab430109a9895176daedb56afbe77c803 Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 26 Nov 2025 01:01:00 +0530 Subject: [PATCH 14/15] debrid integration. Torbox --- src/navigation/AppNavigator.tsx | 331 +++++----- src/screens/AddonsScreen.tsx | 364 +++++------ src/screens/DebridIntegrationScreen.tsx | 797 ++++++++++++++++++++++++ src/screens/SettingsScreen.tsx | 96 +-- 4 files changed, 1210 insertions(+), 378 deletions(-) create mode 100644 src/screens/DebridIntegrationScreen.tsx diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 0fba6733..2f350dc2 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -70,6 +70,7 @@ import BackdropGalleryScreen from '../screens/BackdropGalleryScreen'; import BackupScreen from '../screens/BackupScreen'; import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen'; import ContributorsScreen from '../screens/ContributorsScreen'; +import DebridIntegrationScreen from '../screens/DebridIntegrationScreen'; // Stack navigator types export type RootStackParamList = { @@ -82,27 +83,27 @@ export type RootStackParamList = { Update: undefined; Search: undefined; Calendar: undefined; - Metadata: { - id: string; + Metadata: { + id: string; type: string; episodeId?: string; addonId?: string; }; - Streams: { - id: string; + Streams: { + id: string; type: string; episodeId?: string; episodeThumbnail?: string; fromPlayer?: boolean; }; - PlayerIOS: { - uri: string; - title?: string; - season?: number; - episode?: number; - episodeTitle?: string; - quality?: string; - year?: number; + PlayerIOS: { + uri: string; + title?: string; + season?: number; + episode?: number; + episodeTitle?: string; + quality?: string; + year?: number; streamProvider?: string; streamName?: string; headers?: { [key: string]: string }; @@ -116,14 +117,14 @@ export type RootStackParamList = { videoType?: string; groupedEpisodes?: { [seasonNumber: number]: any[] }; }; - PlayerAndroid: { - uri: string; - title?: string; - season?: number; - episode?: number; - episodeTitle?: string; - quality?: string; - year?: number; + PlayerAndroid: { + uri: string; + title?: string; + season?: number; + episode?: number; + episodeTitle?: string; + quality?: string; + year?: number; streamProvider?: string; streamName?: string; headers?: { [key: string]: string }; @@ -180,6 +181,7 @@ export type RootStackParamList = { }; ContinueWatchingSettings: undefined; Contributors: undefined; + DebridIntegration: undefined; }; export type RootStackNavigationProp = NativeStackNavigationProp; @@ -376,9 +378,9 @@ export const CustomNavigationDarkTheme: Theme = { type IconNameType = string; // Add TabIcon component -const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material' }: { - focused: boolean; - color: string; +const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material' }: { + focused: boolean; + color: string; iconName: IconNameType; iconLibrary?: 'material' | 'feather' | 'ionicons'; }) => { @@ -403,28 +405,28 @@ const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material' })(); return ( - {iconLibrary === 'feather' ? ( - ) : iconLibrary === 'ionicons' ? ( - ) : ( - )} @@ -432,17 +434,17 @@ const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material' }); // Update the TabScreenWrapper component with fixed layout dimensions -const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) => { +const TabScreenWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [dimensions, setDimensions] = useState(Dimensions.get('window')); - + useEffect(() => { const subscription = Dimensions.addEventListener('change', ({ window }) => { setDimensions(window); }); - + return () => subscription?.remove(); }, []); - + const isTablet = useMemo(() => { const { width, height } = dimensions; const smallestDimension = Math.min(width, height); @@ -456,35 +458,35 @@ const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) = StatusBar.setTranslucent(true); StatusBar.setBackgroundColor('transparent'); }; - + applyStatusBarConfig(); - + // Apply status bar config on every focus - const subscription = Platform.OS === 'android' + const subscription = Platform.OS === 'android' ? AppState.addEventListener('change', (state) => { - if (state === 'active') { - applyStatusBarConfig(); - } - }) - : { remove: () => {} }; - + if (state === 'active') { + applyStatusBarConfig(); + } + }) + : { remove: () => { } }; + return () => { subscription.remove(); }; }, []); return ( - {/* Reserve consistent space for the header area on all screens */} - = ({ children }) = }; // Add this component to wrap each screen in the tab navigator -const WrappedScreen: React.FC<{Screen: React.ComponentType}> = ({ Screen }) => { +const WrappedScreen: React.FC<{ Screen: React.ComponentType }> = ({ Screen }) => { return ( @@ -514,12 +516,12 @@ const MainTabs = () => { const { settings: appSettings } = useSettingsHook(); const [hasUpdateBadge, setHasUpdateBadge] = React.useState(false); const [dimensions, setDimensions] = useState(Dimensions.get('window')); - + useEffect(() => { const subscription = Dimensions.addEventListener('change', ({ window }) => { setDimensions(window); }); - + return () => subscription?.remove(); }, []); React.useEffect(() => { @@ -529,7 +531,7 @@ const MainTabs = () => { try { const flag = await mmkvStorage.getItem('@update_badge_pending'); if (mounted) setHasUpdateBadge(flag === 'true'); - } catch {} + } catch { } }; load(); // Fast poll initially for quick badge appearance, then slow down @@ -575,7 +577,7 @@ const MainTabs = () => { }, [hidden, headerAnim]); const translateY = headerAnim.interpolate({ inputRange: [0, 1], outputRange: [0, -70] }); const fade = headerAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }); - + const renderTabBar = (props: BottomTabBarProps) => { // Hide tab bar when home is loading if (isHomeLoading) { @@ -590,18 +592,18 @@ const MainTabs = () => { // Top floating, text-only pill nav for tablets return ( + style={[{ + position: 'absolute', + top: insets.top + 12, + left: 0, + right: 0, + alignItems: 'center', + backgroundColor: 'transparent', + zIndex: 100, + }, shouldKeepFixed ? {} : { + transform: [{ translateY }], + opacity: fade, + }]}> { options.tabBarLabel !== undefined ? options.tabBarLabel : options.title !== undefined - ? options.title - : route.name; + ? options.title + : route.name; const isFocused = props.state.index === index; @@ -692,10 +694,10 @@ const MainTabs = () => { // Default bottom tab for phones return ( - { options.tabBarLabel !== undefined ? options.tabBarLabel : options.title !== undefined - ? options.title - : route.name; + ? options.title + : route.name; const isFocused = props.state.index === index; @@ -813,9 +815,9 @@ const MainTabs = () => { backgroundColor: 'transparent', }} > - @@ -838,7 +840,7 @@ const MainTabs = () => { ); }; - + // iOS: Use native bottom tabs (@bottom-tabs/react-navigation) if (Platform.OS === 'ios') { // Dynamically require to avoid impacting Android bundle @@ -923,7 +925,7 @@ const MainTabs = () => { barStyle="light-content" backgroundColor="transparent" /> - + ({ @@ -1059,7 +1061,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta const { currentTheme } = useTheme(); const { user, loading } = useAccount(); const insets = useSafeAreaInsets(); - + // Handle Android-specific optimizations useEffect(() => { if (Platform.OS === 'android') { @@ -1070,13 +1072,13 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta } catch (error) { console.log('Immersive mode error:', error); } - + // Ensure consistent background color for Android StatusBar.setBackgroundColor('transparent', true); StatusBar.setTranslucent(true); } }, []); - + return ( - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -1571,8 +1588,8 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }; const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => ( - StyleSheet.create({ opacity: 0.8, }, communityAddonVersion: { - fontSize: 12, - color: colors.lightGray, + fontSize: 12, + color: colors.lightGray, }, communityAddonDot: { fontSize: 12, @@ -533,18 +533,18 @@ const createStyles = (colors: any) => StyleSheet.create({ marginHorizontal: 5, }, communityAddonCategory: { - fontSize: 12, - color: colors.lightGray, - flexShrink: 1, + fontSize: 12, + color: colors.lightGray, + flexShrink: 1, }, separator: { height: 10, }, sectionSeparator: { - height: 1, - backgroundColor: colors.border, - marginHorizontal: 20, - marginVertical: 20, + height: 1, + backgroundColor: colors.border, + marginHorizontal: 20, + marginVertical: 20, }, emptyMessage: { textAlign: 'center', @@ -660,16 +660,26 @@ const AddonsScreen = () => { setLoading(true); // Use the regular method without disabled state const installedAddons = await stremioService.getInstalledAddonsAsync(); - setAddons(installedAddons as ExtendedManifest[]); - + + // Filter out Torbox addons (managed via DebridIntegrationScreen) + const filteredAddons = installedAddons.filter(addon => { + const isTorboxAddon = + addon.id?.includes('torbox') || + addon.url?.includes('torbox') || + (addon as any).transport?.includes('torbox'); + return !isTorboxAddon; + }); + + setAddons(filteredAddons as ExtendedManifest[]); + // Count catalogs let totalCatalogs = 0; - installedAddons.forEach(addon => { + filteredAddons.forEach(addon => { if (addon.catalogs && addon.catalogs.length > 0) { totalCatalogs += addon.catalogs.length; } }); - + // Get catalog settings to determine enabled count const catalogSettingsJson = await mmkvStorage.getItem('catalog_settings'); if (catalogSettingsJson) { @@ -682,11 +692,11 @@ const AddonsScreen = () => { setCatalogCount(totalCatalogs); } } catch (error) { - logger.error('Failed to load addons:', error); - setAlertTitle('Error'); - setAlertMessage('Failed to load addons'); - setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); - setAlertVisible(true); + logger.error('Failed to load addons:', error); + setAlertTitle('Error'); + setAlertMessage('Failed to load addons'); + setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); } finally { setLoading(false); } @@ -706,9 +716,9 @@ const AddonsScreen = () => { setCommunityAddons(validAddons); } catch (error) { - logger.error('Failed to load community addons:', error); - setCommunityError('Failed to load community addons. Please try again later.'); - setCommunityAddons([]); + logger.error('Failed to load community addons:', error); + setCommunityError('Failed to load community addons. Please try again later.'); + setCommunityAddons([]); } finally { setCommunityLoading(false); } @@ -756,16 +766,16 @@ const AddonsScreen = () => { setShowConfirmModal(false); setAddonDetails(null); loadAddons(); - setAlertTitle('Success'); - setAlertMessage('Addon installed successfully'); - setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); - setAlertVisible(true); + setAlertTitle('Success'); + setAlertMessage('Addon installed successfully'); + setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); } catch (error) { - logger.error('Failed to install addon:', error); - setAlertTitle('Error'); - setAlertMessage('Failed to install addon'); - setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); - setAlertVisible(true); + logger.error('Failed to install addon:', error); + setAlertTitle('Error'); + setAlertMessage('Failed to install addon'); + setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); } finally { setInstalling(false); } @@ -813,13 +823,13 @@ const AddonsScreen = () => { const handleConfigureAddon = (addon: ExtendedManifest, transportUrl?: string) => { // Try different ways to get the configuration URL let configUrl = ''; - + // Debug log the addon data to help troubleshoot logger.info(`Configure addon: ${addon.name}, ID: ${addon.id}`); if (transportUrl) { logger.info(`TransportUrl provided: ${transportUrl}`); } - + // First check if the addon has a configurationURL directly if (addon.behaviorHints?.configurationURL) { configUrl = addon.behaviorHints.configurationURL; @@ -861,7 +871,7 @@ const AddonsScreen = () => { const baseUrl = addon.id.replace(/\/[^\/]+\.json$/, '/'); configUrl = `${baseUrl}configure`; logger.info(`Using addon.id as HTTP URL: ${configUrl}`); - } + } // If the ID uses stremio:// protocol but contains http URL (common format) else if (addon.id && (addon.id.includes('https://') || addon.id.includes('http://'))) { // Extract the HTTP URL using a more flexible regex @@ -874,7 +884,7 @@ const AddonsScreen = () => { logger.info(`Extracted HTTP URL from stremio:// format: ${configUrl}`); } } - + // Special case for common addon format like stremio://addon.stremio.com/... if (!configUrl && addon.id && addon.id.startsWith('stremio://')) { // Try to convert stremio://domain.com/... to https://domain.com/... @@ -886,21 +896,21 @@ const AddonsScreen = () => { logger.info(`Converted stremio:// protocol to https:// for config URL: ${configUrl}`); } } - + // Use transport property if available (some addons include this) if (!configUrl && addon.transport && typeof addon.transport === 'string' && addon.transport.includes('http')) { const baseUrl = addon.transport.replace(/\/[^\/]+\.json$/, '/'); configUrl = `${baseUrl}configure`; logger.info(`Using addon.transport for config URL: ${configUrl}`); } - + // Get the URL from manifest's originalUrl if available if (!configUrl && (addon as any).originalUrl) { const baseUrl = (addon as any).originalUrl.replace(/\/[^\/]+\.json$/, '/'); configUrl = `${baseUrl}configure`; logger.info(`Using originalUrl property: ${configUrl}`); } - + // If we couldn't determine a config URL, show an error if (!configUrl) { logger.error(`Failed to determine config URL for addon: ${addon.name}, ID: ${addon.id}`); @@ -910,10 +920,10 @@ const AddonsScreen = () => { setAlertVisible(true); return; } - + // Log the URL being opened logger.info(`Opening configuration for addon: ${addon.name} at URL: ${configUrl}`); - + // Check if the URL can be opened Linking.canOpenURL(configUrl).then(supported => { if (supported) { @@ -927,10 +937,10 @@ const AddonsScreen = () => { } }).catch(err => { logger.error(`Error checking if URL can be opened: ${configUrl}`, err); - setAlertTitle('Error'); - setAlertMessage('Could not open configuration page.'); - setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); - setAlertVisible(true); + setAlertTitle('Error'); + setAlertMessage('Could not open configuration page.'); + setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); }); }; @@ -947,12 +957,12 @@ const AddonsScreen = () => { const isConfigurable = item.behaviorHints?.configurable === true; // Check if addon is pre-installed const isPreInstalled = stremioService.isPreInstalledAddon(item.id); - + // Format the types into a simple category text - const categoryText = types.length > 0 - ? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') + const categoryText = types.length > 0 + ? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') : 'No categories'; - + const isFirstItem = index === 0; const isLastItem = index === addons.length - 1; @@ -960,35 +970,35 @@ const AddonsScreen = () => { {reorderMode && ( - moveAddonUp(item)} disabled={isFirstItem} > - - moveAddonDown(item)} disabled={isLastItem} > - )} - + {logo ? ( - @@ -1016,7 +1026,7 @@ const AddonsScreen = () => { {!reorderMode ? ( <> {isConfigurable && ( - handleConfigureAddon(item, item.transport)} > @@ -1024,7 +1034,7 @@ const AddonsScreen = () => { )} {!stremioService.isPreInstalledAddon(item.id) && ( - handleRemoveAddon(item)} > @@ -1039,7 +1049,7 @@ const AddonsScreen = () => { )} - + {description.length > 100 ? description.substring(0, 100) + '...' : description} @@ -1077,9 +1087,9 @@ const AddonsScreen = () => { {manifest.name} {description} - v{manifest.version || 'N/A'} - - {categoryText} + v{manifest.version || 'N/A'} + + {categoryText} @@ -1117,50 +1127,50 @@ const AddonsScreen = () => { return ( - + {/* Header */} - navigation.goBack()} > Settings - + {/* Reorder Mode Toggle Button */} - - - + {/* Refresh Button */} - - - + Addons {reorderMode && (Reorder Mode)} - + {reorderMode && ( @@ -1169,18 +1179,18 @@ const AddonsScreen = () => { )} - + {loading ? ( ) : ( - - + {/* Overview Section */} OVERVIEW @@ -1192,7 +1202,7 @@ const AddonsScreen = () => { - + {/* Hide Add Addon Section in reorder mode */} {!reorderMode && ( @@ -1207,8 +1217,8 @@ const AddonsScreen = () => { autoCapitalize="none" autoCorrect={false} /> - handleAddAddon()} disabled={installing || !addonUrl} > @@ -1219,7 +1229,7 @@ const AddonsScreen = () => { )} - + {/* Installed Addons Section */} @@ -1233,8 +1243,8 @@ const AddonsScreen = () => { ) : ( addons.map((addon, index) => ( - {renderAddonItem({ item: addon, index })} @@ -1245,68 +1255,68 @@ const AddonsScreen = () => { {/* Separator */} - + - {/* Promotional Addon Section (hidden if installed) */} - {!isPromoInstalled && ( - - OFFICIAL ADDON - - - - {promoAddon.logo ? ( - - ) : ( - - - - )} - - {promoAddon.name} - - v{promoAddon.version} - - {promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')} - - - - {promoAddon.behaviorHints?.configurable && ( - handleConfigureAddon(promoAddon, PROMO_ADDON_URL)} - > - - - )} - handleAddAddon(PROMO_ADDON_URL)} - disabled={installing} - > - {installing ? ( - - ) : ( - - )} - - - - - {promoAddon.description} - - - Configure and install for full functionality. - - - - - )} + {/* Promotional Addon Section (hidden if installed) */} + {!isPromoInstalled && ( + + OFFICIAL ADDON + + + + {promoAddon.logo ? ( + + ) : ( + + + + )} + + {promoAddon.name} + + v{promoAddon.version} + + {promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')} + + + + {promoAddon.behaviorHints?.configurable && ( + handleConfigureAddon(promoAddon, PROMO_ADDON_URL)} + > + + + )} + handleAddAddon(PROMO_ADDON_URL)} + disabled={installing} + > + {installing ? ( + + ) : ( + + )} + + + + + {promoAddon.description} + + + Configure and install for full functionality. + + + + + )} - {/* Community Addons Section */} + {/* Community Addons Section */} COMMUNITY ADDONS @@ -1326,15 +1336,15 @@ const AddonsScreen = () => { ) : ( communityAddons.map((item, index) => ( - {item.manifest.logo ? ( - @@ -1357,14 +1367,14 @@ const AddonsScreen = () => { {item.manifest.behaviorHints?.configurable && ( - handleConfigureAddon(item.manifest, item.transportUrl)} > )} - handleAddAddon(item.transportUrl)} disabled={installing} @@ -1377,12 +1387,12 @@ const AddonsScreen = () => { - + - {item.manifest.description - ? (item.manifest.description.length > 100 - ? item.manifest.description.substring(0, 100) + '...' - : item.manifest.description) + {item.manifest.description + ? (item.manifest.description.length > 100 + ? item.manifest.description.substring(0, 100) + '...' + : item.manifest.description) : 'No description provided.'} @@ -1429,8 +1439,8 @@ const AddonsScreen = () => { - - { {addonDetails.name} v{addonDetails.version || '1.0.0'} - + Description {addonDetails.description || 'No description available'} - + {addonDetails.types && addonDetails.types.length > 0 && ( Supported Types @@ -1471,7 +1481,7 @@ const AddonsScreen = () => { )} - + {addonDetails.catalogs && addonDetails.catalogs.length > 0 && ( Catalogs @@ -1487,7 +1497,7 @@ const AddonsScreen = () => { )} - + { - {/* Custom Alert Modal */} - setAlertVisible(false)} - actions={alertActions} - /> - + {/* Custom Alert Modal */} + setAlertVisible(false)} + actions={alertActions} + /> + ); }; diff --git a/src/screens/DebridIntegrationScreen.tsx b/src/screens/DebridIntegrationScreen.tsx new file mode 100644 index 00000000..c1365f14 --- /dev/null +++ b/src/screens/DebridIntegrationScreen.tsx @@ -0,0 +1,797 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TextInput, + TouchableOpacity, + SafeAreaView, + StatusBar, + Platform, + Linking, + ScrollView, + KeyboardAvoidingView, + Image, + Switch, + ActivityIndicator, + RefreshControl +} from 'react-native'; +import { useNavigation, useFocusEffect } from '@react-navigation/native'; +import { NavigationProp } from '@react-navigation/native'; +import { RootStackParamList } from '../navigation/AppNavigator'; +import { useTheme } from '../contexts/ThemeContext'; +import { Feather, MaterialCommunityIcons } from '@expo/vector-icons'; +import { stremioService } from '../services/stremioService'; +import { logger } from '../utils/logger'; +import CustomAlert from '../components/CustomAlert'; +import { mmkvStorage } from '../services/mmkvStorage'; +import axios from 'axios'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +const TORBOX_STORAGE_KEY = 'torbox_debrid_config'; +const TORBOX_API_BASE = 'https://api.torbox.app/v1'; + +interface TorboxConfig { + apiKey: string; + isConnected: boolean; + isEnabled: boolean; + addonId?: string; +} + +interface TorboxUserData { + id: number; + email: string; + plan: number; + total_downloaded: number; + is_subscribed: boolean; + premium_expires_at: string | null; + base_email: string; +} + +const getPlanName = (plan: number): string => { + switch (plan) { + case 0: return 'Free'; + case 1: return 'Essential ($3/mo)'; + case 2: return 'Pro ($10/mo)'; + case 3: return 'Standard ($5/mo)'; + default: return 'Unknown'; + } +}; +const createStyles = (colors: any) => StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.darkBackground, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + paddingBottom: 8, + }, + backButton: { + padding: 8, + marginRight: 4, + }, + headerTitle: { + fontSize: 20, + fontWeight: '700', + color: colors.white, + letterSpacing: 0.3, + }, + content: { + flex: 1, + paddingHorizontal: 16, + }, + description: { + fontSize: 14, + color: colors.mediumEmphasis, + marginBottom: 16, + lineHeight: 20, + opacity: 0.9, + }, + statusCard: { + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + statusRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + statusLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.mediumEmphasis, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + statusValue: { + fontSize: 15, + fontWeight: '700', + color: colors.white, + }, + statusConnected: { + color: colors.success || '#4CAF50', + }, + statusDisconnected: { + color: colors.error || '#F44336', + }, + divider: { + height: 1, + backgroundColor: colors.elevation3, + marginVertical: 10, + }, + actionButton: { + borderRadius: 10, + padding: 12, + alignItems: 'center', + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + primaryButton: { + backgroundColor: colors.primary, + }, + dangerButton: { + backgroundColor: colors.error || '#F44336', + }, + buttonText: { + color: colors.white, + fontSize: 14, + fontWeight: '700', + letterSpacing: 0.3, + }, + inputContainer: { + marginBottom: 16, + }, + label: { + fontSize: 12, + fontWeight: '600', + color: colors.white, + marginBottom: 6, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + input: { + backgroundColor: colors.elevation2, + borderRadius: 10, + padding: 12, + color: colors.white, + fontSize: 14, + borderWidth: 1, + borderColor: colors.elevation3, + }, + connectButton: { + backgroundColor: colors.primary, + borderRadius: 10, + padding: 14, + alignItems: 'center', + marginBottom: 16, + shadowColor: colors.primary, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 4, + elevation: 3, + }, + connectButtonText: { + color: colors.white, + fontSize: 15, + fontWeight: '700', + letterSpacing: 0.5, + }, + disabledButton: { + opacity: 0.5, + }, + section: { + marginTop: 16, + backgroundColor: colors.elevation1, + borderRadius: 12, + padding: 16, + alignItems: 'center', + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.white, + marginBottom: 6, + letterSpacing: 0.3, + }, + sectionText: { + fontSize: 13, + color: colors.mediumEmphasis, + textAlign: 'center', + marginBottom: 12, + lineHeight: 18, + opacity: 0.9, + }, + subscribeButton: { + backgroundColor: colors.elevation3, + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 8, + }, + subscribeButtonText: { + color: colors.primary, + fontWeight: '700', + fontSize: 13, + letterSpacing: 0.3, + }, + logoContainer: { + alignItems: 'center', + marginTop: 'auto', + paddingBottom: 16, + paddingTop: 16, + }, + poweredBy: { + fontSize: 10, + color: colors.mediumGray, + marginBottom: 6, + textTransform: 'uppercase', + letterSpacing: 1, + opacity: 0.6, + }, + logo: { + width: 48, + height: 48, + marginBottom: 4, + }, + logoRow: { + alignItems: 'center', + justifyContent: 'center', + marginBottom: 4, + }, + logoText: { + fontSize: 20, + fontWeight: '700', + color: colors.white, + letterSpacing: 0.5, + }, + userDataCard: { + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + userDataRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 6, + }, + userDataLabel: { + fontSize: 13, + color: colors.mediumEmphasis, + flex: 1, + letterSpacing: 0.2, + }, + userDataValue: { + fontSize: 14, + fontWeight: '600', + color: colors.white, + flex: 1, + textAlign: 'right', + letterSpacing: 0.2, + }, + planBadge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 6, + alignSelf: 'flex-start', + }, + planBadgeFree: { + backgroundColor: colors.elevation3, + }, + planBadgePaid: { + backgroundColor: colors.primary + '20', + }, + planBadgeText: { + fontSize: 12, + fontWeight: '700', + letterSpacing: 0.3, + }, + planBadgeTextFree: { + color: colors.mediumEmphasis, + }, + planBadgeTextPaid: { + color: colors.primary, + }, + userDataHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + paddingBottom: 8, + borderBottomWidth: 1, + borderBottomColor: colors.elevation3, + }, + userDataTitle: { + fontSize: 15, + fontWeight: '700', + color: colors.white, + letterSpacing: 0.3, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + guideLink: { + marginBottom: 16, + alignSelf: 'flex-start', + }, + guideLinkText: { + color: colors.primary, + fontSize: 13, + fontWeight: '600', + textDecorationLine: 'underline', + }, + disclaimer: { + fontSize: 10, + color: colors.mediumGray, + textAlign: 'center', + marginTop: 8, + opacity: 0.6, + } +}); + +const DebridIntegrationScreen = () => { + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const colors = currentTheme.colors; + const styles = createStyles(colors); + + const [apiKey, setApiKey] = useState(''); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const [config, setConfig] = useState(null); + const [userData, setUserData] = useState(null); + const [userDataLoading, setUserDataLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + // Alert state + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState([]); + + const loadConfig = useCallback(async () => { + try { + const storedConfig = await mmkvStorage.getItem(TORBOX_STORAGE_KEY); + if (storedConfig) { + const parsedConfig = JSON.parse(storedConfig); + setConfig(parsedConfig); + + // Check if addon is actually installed + const addons = await stremioService.getInstalledAddonsAsync(); + const torboxAddon = addons.find(addon => + addon.id?.includes('torbox') || + addon.url?.includes('torbox') || + (addon as any).transport?.includes('torbox') + ); + + if (torboxAddon && !parsedConfig.isConnected) { + // Update config if addon exists but config says not connected + const updatedConfig = { ...parsedConfig, isConnected: true, addonId: torboxAddon.id }; + setConfig(updatedConfig); + await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(updatedConfig)); + } else if (!torboxAddon && parsedConfig.isConnected) { + // Update config if addon doesn't exist but config says connected + const updatedConfig = { ...parsedConfig, isConnected: false, addonId: undefined }; + setConfig(updatedConfig); + await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(updatedConfig)); + } + } + } catch (error) { + logger.error('Failed to load Torbox config:', error); + } finally { + setInitialLoading(false); + } + }, []); + + const fetchUserData = useCallback(async () => { + if (!config?.apiKey || !config?.isConnected) return; + + setUserDataLoading(true); + try { + const response = await axios.get(`${TORBOX_API_BASE}/api/user/me`, { + headers: { + 'Authorization': `Bearer ${config.apiKey}` + }, + params: { + settings: false + } + }); + + if (response.data.success && response.data.data) { + setUserData(response.data.data); + } + } catch (error) { + logger.error('Failed to fetch Torbox user data:', error); + // Don't show error to user, just log it + } finally { + setUserDataLoading(false); + } + }, [config]); + + useFocusEffect( + useCallback(() => { + loadConfig(); + }, [loadConfig]) + ); + + useEffect(() => { + if (config?.isConnected) { + fetchUserData(); + } + }, [config?.isConnected, fetchUserData]); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await Promise.all([loadConfig(), fetchUserData()]); + setRefreshing(false); + }, [loadConfig, fetchUserData]); + + const handleConnect = async () => { + if (!apiKey.trim()) { + setAlertTitle('Error'); + setAlertMessage('Please enter a valid API Key'); + setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); + return; + } + + setLoading(true); + try { + const manifestUrl = `https://stremio.torbox.app/${apiKey.trim()}/manifest.json`; + + // Install the addon using stremioService + await stremioService.installAddon(manifestUrl); + + // Get the installed addon ID + const addons = await stremioService.getInstalledAddonsAsync(); + const torboxAddon = addons.find(addon => + addon.id?.includes('torbox') || + addon.url?.includes('torbox') || + (addon as any).transport?.includes('torbox') + ); + + // Save config + const newConfig: TorboxConfig = { + apiKey: apiKey.trim(), + isConnected: true, + isEnabled: true, + addonId: torboxAddon?.id + }; + await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(newConfig)); + setConfig(newConfig); + setApiKey(''); + + setAlertTitle('Success'); + setAlertMessage('Torbox addon connected successfully!'); + setAlertActions([{ + label: 'OK', + onPress: () => setAlertVisible(false) + }]); + setAlertVisible(true); + } catch (error) { + logger.error('Failed to install Torbox addon:', error); + setAlertTitle('Error'); + setAlertMessage('Failed to connect addon. Please check your API Key and try again.'); + setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); + } finally { + setLoading(false); + } + }; + + const handleToggleEnabled = async (enabled: boolean) => { + if (!config) return; + + try { + const updatedConfig = { ...config, isEnabled: enabled }; + await mmkvStorage.setItem(TORBOX_STORAGE_KEY, JSON.stringify(updatedConfig)); + setConfig(updatedConfig); + + // Note: Since we can't disable/enable addons in the current stremioService, + // we'll just track the state. The addon filtering will happen in AddonsScreen + } catch (error) { + logger.error('Failed to toggle Torbox addon:', error); + } + }; + + const handleDisconnect = async () => { + setAlertTitle('Disconnect Torbox'); + setAlertMessage('Are you sure you want to disconnect Torbox? This will remove the addon and clear your saved API key.'); + setAlertActions([ + { label: 'Cancel', onPress: () => setAlertVisible(false), style: { color: colors.mediumGray } }, + { + label: 'Disconnect', + onPress: async () => { + setAlertVisible(false); + setLoading(true); + try { + // Find and remove the torbox addon + const addons = await stremioService.getInstalledAddonsAsync(); + const torboxAddon = addons.find(addon => + addon.id?.includes('torbox') || + addon.url?.includes('torbox') || + (addon as any).transport?.includes('torbox') + ); + + if (torboxAddon) { + await stremioService.removeAddon(torboxAddon.id); + } + + // Clear config + await mmkvStorage.removeItem(TORBOX_STORAGE_KEY); + setConfig(null); + + setAlertTitle('Success'); + setAlertMessage('Torbox disconnected successfully'); + setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); + } catch (error) { + logger.error('Failed to disconnect Torbox:', error); + setAlertTitle('Error'); + setAlertMessage('Failed to disconnect Torbox'); + setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); + } finally { + setLoading(false); + } + }, + style: { color: colors.error || '#F44336' } + } + ]); + setAlertVisible(true); + }; + + const openSubscription = () => { + Linking.openURL('https://torbox.app/subscription?referral=493192f2-6403-440f-b414-768f72222ec7'); + }; + + if (initialLoading) { + return ( + + + + + + + ); + } + + return ( + + + + + navigation.goBack()} + style={styles.backButton} + > + + + Debrid Integration + + + + + } + > + {config?.isConnected ? ( + // Connected state + <> + + + Status + Connected + + + + + + Enable Addon + + + + + + + {loading ? 'Disconnecting...' : 'Disconnect & Remove'} + + + + {/* User Data Card */} + {userData && ( + + + Account Information + {userDataLoading && ( + + )} + + + + Email + + {userData.base_email || userData.email} + + + + + Plan + + + {getPlanName(userData.plan)} + + + + + + Status + + {userData.is_subscribed ? 'Active' : 'Free'} + + + + {userData.premium_expires_at && ( + + Expires + + {new Date(userData.premium_expires_at).toLocaleDateString()} + + + )} + + + Downloaded + + {(userData.total_downloaded / (1024 * 1024 * 1024)).toFixed(2)} GB + + + + )} + + + ✓ Connected to TorBox + + Your TorBox addon is active and providing premium streams.{config.isEnabled ? '' : ' (Currently disabled)'} + + + + + Configure Addon + + Customize your streaming experience. Sort by quality, filter file sizes, and manage other integration settings. + + Linking.openURL('https://torbox.app/settings?section=integration-settings')} + > + Open Settings + + + + ) : ( + // Not connected state + <> + + Unlock 4K high-quality streams and lightning-fast speeds by integrating Torbox. Enter your API Key below to instantly upgrade your streaming experience. + + + Linking.openURL('https://guides.viren070.me/stremio/technical-details#debrid-services')} style={styles.guideLink}> + What is a Debrid Service? + + + + Torbox API Key + + + + + + {loading ? 'Connecting...' : 'Connect & Install'} + + + + + Unlock Premium Speeds + + Get a Torbox subscription to access cached high-quality streams with zero buffering. + + + Get Subscription + + + + )} + + + Powered by + + + TorBox + + Nuvio is not affiliated with Torbox in any way. + + + + + setAlertVisible(false)} + /> + + ); +}; + +export default DebridIntegrationScreen; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index e068a20f..3d93e035 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -67,9 +67,9 @@ interface SettingsCardProps { const SettingsCard: React.FC = ({ children, title, isTablet = false }) => { const { currentTheme } = useTheme(); - + return ( - = ({ isTablet = false }) => { const { currentTheme } = useTheme(); - + return ( - = ({ > = ({ {customIcon ? ( customIcon ) : ( - )} @@ -161,7 +161,7 @@ const SettingItem: React.FC = ({ {description && ( @@ -224,16 +224,16 @@ const Sidebar: React.FC = ({ selectedCategory, onCategorySelect, c name={category.icon as any} size={22} color={ - selectedCategory === category.id - ? currentTheme.colors.primary + selectedCategory === category.id + ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis } /> @@ -263,7 +263,7 @@ const SettingsScreen: React.FC = () => { ) => { setAlertTitle(title); setAlertMessage(message); - setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); + setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]); setAlertVisible(true); }; @@ -274,7 +274,7 @@ const SettingsScreen: React.FC = () => { try { const flag = await mmkvStorage.getItem('@update_badge_pending'); if (mounted) setHasUpdateBadge(flag === 'true'); - } catch {} + } catch { } })(); return () => { mounted = false; }; }, []); @@ -283,7 +283,7 @@ const SettingsScreen: React.FC = () => { const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext(); const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); - + // Tablet-specific state const [selectedCategory, setSelectedCategory] = useState('account'); @@ -310,7 +310,7 @@ const SettingsScreen: React.FC = () => { } refreshAuthStatus(); }); - + return unsubscribe; }, [navigation, isAuthenticated, userProfile, refreshAuthStatus]); @@ -320,7 +320,7 @@ const SettingsScreen: React.FC = () => { const addons = await stremioService.getInstalledAddonsAsync(); setAddonCount(addons.length); setInitialLoadComplete(true); - + // Count total available catalogs let totalCatalogs = 0; addons.forEach(addon => { @@ -328,7 +328,7 @@ const SettingsScreen: React.FC = () => { totalCatalogs += addon.catalogs.length; } }); - + // Load saved catalog settings const catalogSettingsJson = await mmkvStorage.getItem('catalog_settings'); if (catalogSettingsJson) { @@ -358,7 +358,7 @@ const SettingsScreen: React.FC = () => { setTotalDownloads(downloads); setDisplayDownloads(downloads); } - + } catch (error) { if (__DEV__) console.error('Error loading settings data:', error); } @@ -382,7 +382,7 @@ const SettingsScreen: React.FC = () => { useEffect(() => { // Only poll when viewing the About section (where downloads counter is shown) const shouldPoll = isTablet ? selectedCategory === 'about' : true; - + if (!shouldPoll) return; const pollInterval = setInterval(async () => { @@ -414,11 +414,11 @@ const SettingsScreen: React.FC = () => { const now = Date.now(); const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); - + // Ease out quad for smooth deceleration const easeProgress = 1 - Math.pow(1 - progress, 2); const current = Math.floor(start + (end - start) * easeProgress); - + setDisplayDownloads(current); if (progress < 1) { @@ -437,7 +437,7 @@ const SettingsScreen: React.FC = () => { 'Reset Settings', 'Are you sure you want to reset all settings to default values?', [ - { label: 'Cancel', onPress: () => {} }, + { label: 'Cancel', onPress: () => { } }, { label: 'Reset', onPress: () => { @@ -455,7 +455,7 @@ const SettingsScreen: React.FC = () => { 'Clear MDBList Cache', 'Are you sure you want to clear all cached MDBList data? This cannot be undone.', [ - { label: 'Cancel', onPress: () => {} }, + { label: 'Cancel', onPress: () => { } }, { label: 'Clear', onPress: async () => { @@ -483,9 +483,9 @@ const SettingsScreen: React.FC = () => { ); const ChevronRight = () => ( - ); @@ -527,6 +527,14 @@ const SettingsScreen: React.FC = () => { onPress={() => navigation.navigate('Addons')} isTablet={isTablet} /> + navigation.navigate('DebridIntegration')} + isTablet={isTablet} + /> { { 'Clear All Data', 'This will reset all settings and clear all cached data. Are you sure?', [ - { label: 'Cancel', onPress: () => {} }, + { label: 'Cancel', onPress: () => { } }, { label: 'Clear', onPress: async () => { @@ -824,7 +832,7 @@ const SettingsScreen: React.FC = () => { badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined} onPress={async () => { if (Platform.OS === 'android') { - try { await mmkvStorage.removeItem('@update_badge_pending'); } catch {} + try { await mmkvStorage.removeItem('@update_badge_pending'); } catch { } setHasUpdateBadge(false); } navigation.navigate('Update'); @@ -861,20 +869,20 @@ const SettingsScreen: React.FC = () => { categories={visibleCategories} extraTopPadding={tabletNavOffset} /> - + - {renderCategoryContent(selectedCategory)} - + {selectedCategory === 'about' && ( <> {displayDownloads !== null && ( @@ -887,9 +895,9 @@ const SettingsScreen: React.FC = () => { )} - + - + Made with ❤️ by Tapframe and Friends @@ -906,7 +914,7 @@ const SettingsScreen: React.FC = () => { style={styles.discordLogo} resizeMode={FastImage.resizeMode.contain} /> - + Join Discord @@ -958,7 +966,7 @@ const SettingsScreen: React.FC = () => { - { style={styles.discordLogo} resizeMode={FastImage.resizeMode.contain} /> - + Join Discord @@ -1074,7 +1082,7 @@ const styles = StyleSheet.create({ width: '100%', paddingBottom: 90, }, - + // Tablet-specific styles tabletContainer: { flex: 1, @@ -1128,7 +1136,7 @@ const styles = StyleSheet.create({ tabletScrollContent: { paddingBottom: 32, }, - + // Common card styles cardContainer: { width: '100%', From 6c08b459bf4fb3c1db7081752c143801b7abf965 Mon Sep 17 00:00:00 2001 From: tapframe Date: Wed, 26 Nov 2025 01:09:30 +0530 Subject: [PATCH 15/15] ui changes --- App.tsx | 74 ++++-- src/components/AnnouncementOverlay.tsx | 308 +++++++++++++++++++++++++ src/screens/SettingsScreen.tsx | 15 ++ 3 files changed, 383 insertions(+), 14 deletions(-) create mode 100644 src/components/AnnouncementOverlay.tsx diff --git a/App.tsx b/App.tsx index bd297656..39207b57 100644 --- a/App.tsx +++ b/App.tsx @@ -18,7 +18,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { StatusBar } from 'expo-status-bar'; import { Provider as PaperProvider } from 'react-native-paper'; import { enableScreens, enableFreeze } from 'react-native-screens'; -import AppNavigator, { +import AppNavigator, { CustomNavigationDarkTheme, CustomDarkTheme } from './src/navigation/AppNavigator'; @@ -41,6 +41,7 @@ import { aiService } from './src/services/aiService'; import { AccountProvider, useAccount } from './src/contexts/AccountContext'; import { ToastProvider } from './src/contexts/ToastContext'; import { mmkvStorage } from './src/services/mmkvStorage'; +import AnnouncementOverlay from './src/components/AnnouncementOverlay'; Sentry.init({ dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992', @@ -82,12 +83,13 @@ const ThemedApp = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const engine = (global as any).HermesInternal ? 'Hermes' : 'JSC'; console.log('JS Engine:', engine); - } catch {} + } catch { } }, []); const { currentTheme } = useTheme(); const [isAppReady, setIsAppReady] = useState(false); const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(null); - + const [showAnnouncement, setShowAnnouncement] = useState(false); + // Update popup functionality const { showUpdatePopup, @@ -100,7 +102,17 @@ const ThemedApp = () => { // GitHub major/minor release overlay const githubUpdate = useGithubMajorUpdate(); - + + // Announcement data + const announcements = [ + { + icon: 'zap', + title: 'Debrid Integration', + description: 'Unlock 4K high-quality streams with lightning-fast speeds. Connect your TorBox account to access cached premium content with zero buffering.', + tag: 'NEW', + }, + ]; + // Check onboarding status and initialize services useEffect(() => { const initializeApp = async () => { @@ -108,28 +120,37 @@ const ThemedApp = () => { // Check onboarding status const onboardingCompleted = await mmkvStorage.getItem('hasCompletedOnboarding'); setHasCompletedOnboarding(onboardingCompleted === 'true'); - + // Initialize update service await UpdateService.initialize(); - + // Initialize memory monitoring service to prevent OutOfMemoryError memoryMonitorService; // Just accessing it starts the monitoring console.log('Memory monitoring service initialized'); - + // Initialize AI service await aiService.initialize(); console.log('AI service initialized'); - + + // Check if announcement should be shown (version 1.0.0) + const announcementShown = await mmkvStorage.getItem('announcement_v1.0.0_shown'); + if (!announcementShown && onboardingCompleted === 'true') { + // Show announcement only after app is ready + setTimeout(() => { + setShowAnnouncement(true); + }, 1000); + } + } catch (error) { console.error('Error initializing app:', error); // Default to showing onboarding if we can't check setHasCompletedOnboarding(false); } }; - + initializeApp(); }, []); - + // Create custom themes based on current theme const customDarkTheme = { ...CustomDarkTheme, @@ -138,7 +159,7 @@ const ThemedApp = () => { primary: currentTheme.colors.primary, } }; - + const customNavigationTheme = { ...CustomNavigationDarkTheme, colors: { @@ -153,15 +174,33 @@ const ThemedApp = () => { const handleSplashComplete = () => { setIsAppReady(true); }; - + + // Navigation reference + const navigationRef = React.useRef(null); + + // Handler for navigating to debrid integration + const handleNavigateToDebrid = () => { + if (navigationRef.current) { + navigationRef.current.navigate('DebridIntegration'); + } + }; + + // Handler for announcement close + const handleAnnouncementClose = async () => { + setShowAnnouncement(false); + // Mark announcement as shown + await mmkvStorage.setItem('announcement_v1.0.0_shown', 'true'); + }; + // Don't render anything until we know the onboarding status const shouldShowApp = isAppReady && hasCompletedOnboarding !== null; const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding'; - + return ( - @@ -186,6 +225,13 @@ const ThemedApp = () => { onDismiss={githubUpdate.onDismiss} onLater={githubUpdate.onLater} /> + diff --git a/src/components/AnnouncementOverlay.tsx b/src/components/AnnouncementOverlay.tsx new file mode 100644 index 00000000..4a352e2b --- /dev/null +++ b/src/components/AnnouncementOverlay.tsx @@ -0,0 +1,308 @@ +import React, { useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, + Animated, + Dimensions, + ScrollView, +} from 'react-native'; +import { useTheme } from '../contexts/ThemeContext'; +import { Feather } from '@expo/vector-icons'; + +const { width, height } = Dimensions.get('window'); + +interface Announcement { + icon: string; + title: string; + description: string; + tag?: string; +} + +interface AnnouncementOverlayProps { + visible: boolean; + onClose: () => void; + onActionPress?: () => void; + title?: string; + announcements: Announcement[]; + actionButtonText?: string; +} + +const AnnouncementOverlay: React.FC = ({ + visible, + onClose, + onActionPress, + title = "What's New", + announcements, + actionButtonText = "Got it!", +}) => { + const { currentTheme } = useTheme(); + const colors = currentTheme.colors; + + const scaleAnim = useRef(new Animated.Value(0.8)).current; + const opacityAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (visible) { + Animated.parallel([ + Animated.spring(scaleAnim, { + toValue: 1, + tension: 50, + friction: 7, + useNativeDriver: true, + }), + Animated.timing(opacityAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + ]).start(); + } else { + scaleAnim.setValue(0.8); + opacityAnim.setValue(0); + } + }, [visible]); + + const handleClose = () => { + Animated.parallel([ + Animated.spring(scaleAnim, { + toValue: 0.8, + tension: 50, + friction: 7, + useNativeDriver: true, + }), + Animated.timing(opacityAnim, { + toValue: 0, + duration: 200, + useNativeDriver: true, + }), + ]).start(() => { + onClose(); + }); + }; + + const handleAction = () => { + if (onActionPress) { + handleClose(); + // Delay navigation slightly to allow animation to complete + setTimeout(() => { + onActionPress(); + }, 300); + } else { + handleClose(); + } + }; + + return ( + + + + + + {/* Close Button */} + + + + + {/* Header */} + + + + + {title} + + Exciting updates in this release + + + + {/* Announcements */} + + {announcements.map((announcement, index) => ( + + + + + + + + {announcement.title} + + {announcement.tag && ( + + {announcement.tag} + + )} + + + {announcement.description} + + + + ))} + + + {/* Action Button */} + + {actionButtonText} + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.85)', + }, + container: { + width: width * 0.9, + maxWidth: 500, + maxHeight: height * 0.8, + }, + card: { + backgroundColor: '#1a1a1a', + borderRadius: 24, + padding: 24, + shadowColor: '#000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.3, + shadowRadius: 16, + }, + closeButton: { + position: 'absolute', + top: 16, + right: 16, + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: '#2a2a2a', + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + }, + header: { + alignItems: 'center', + marginBottom: 24, + }, + iconContainer: { + width: 64, + height: 64, + borderRadius: 32, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + title: { + fontSize: 28, + fontWeight: '700', + letterSpacing: 0.5, + marginBottom: 8, + }, + subtitle: { + fontSize: 14, + textAlign: 'center', + opacity: 0.9, + }, + scrollView: { + maxHeight: height * 0.45, + marginBottom: 20, + }, + announcementItem: { + backgroundColor: '#252525', + flexDirection: 'row', + padding: 16, + borderRadius: 16, + marginBottom: 12, + }, + announcementIcon: { + width: 48, + height: 48, + borderRadius: 24, + justifyContent: 'center', + alignItems: 'center', + marginRight: 16, + }, + announcementContent: { + flex: 1, + }, + announcementHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 6, + }, + announcementTitle: { + fontSize: 16, + fontWeight: '700', + letterSpacing: 0.3, + flex: 1, + }, + tag: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + marginLeft: 8, + }, + tagText: { + fontSize: 10, + fontWeight: '700', + color: '#FFFFFF', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + announcementDescription: { + fontSize: 14, + lineHeight: 20, + opacity: 0.9, + }, + button: { + borderRadius: 12, + paddingVertical: 16, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 4, + }, + buttonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '700', + letterSpacing: 0.5, + }, +}); + +export default AnnouncementOverlay; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 3d93e035..bf7fa4ed 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -764,6 +764,21 @@ const SettingsScreen: React.FC = () => { renderControl={ChevronRight} isTablet={isTablet} /> + { + try { + await mmkvStorage.removeItem('announcement_v1.0.0_shown'); + openAlert('Success', 'Announcement reset. Restart the app to see the announcement overlay.'); + } catch (error) { + openAlert('Error', 'Failed to reset announcement.'); + } + }} + renderControl={ChevronRight} + isTablet={isTablet} + />