From 373efa0564d211290e4d634c7eda49efddcdb3ba Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 21 Oct 2025 14:53:01 +0530 Subject: [PATCH] Implemented caching stream links for faster playback. --- .../home/ContinueWatchingSection.tsx | 86 ++++++- src/screens/StreamsScreen.tsx | 21 ++ src/services/streamCacheService.ts | 210 ++++++++++++++++++ 3 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 src/services/streamCacheService.ts diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 5ed400d..648a2c9 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -7,7 +7,8 @@ import { Dimensions, AppState, AppStateStatus, - ActivityIndicator + ActivityIndicator, + Platform } from 'react-native'; import { FlashList } from '@shopify/flash-list'; import Animated, { FadeIn, Layout } from 'react-native-reanimated'; @@ -23,6 +24,7 @@ import { logger } from '../../utils/logger'; import * as Haptics from 'expo-haptics'; import { TraktService } from '../../services/traktService'; import { stremioService } from '../../services/stremioService'; +import { streamCacheService } from '../../services/streamCacheService'; import CustomAlert from '../../components/CustomAlert'; // Define interface for continue watching items @@ -650,8 +652,84 @@ const ContinueWatchingSection = React.forwardRef((props, re } })); - const handleContentPress = useCallback((id: string, type: string) => { - navigation.navigate('Metadata', { id, type }); + const handleContentPress = useCallback(async (item: ContinueWatchingItem) => { + try { + logger.log(`๐ŸŽฌ [ContinueWatching] User clicked on: ${item.name} (${item.type}:${item.id})`); + + // 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}` + : 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, + title: cachedStream.metadata?.name || item.name, + episodeTitle: cachedStream.episodeTitle || (item.type === 'series' ? `Episode ${item.episode}` : undefined), + season: cachedStream.season || item.season, + episode: cachedStream.episode || item.episode, + quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined, + year: cachedStream.metadata?.year || item.year, + streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name, + streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream', + headers: cachedStream.stream.headers || undefined, + forceVlc: false, + id: item.id, + type: item.type, + episodeId: episodeId, + imdbId: cachedStream.metadata?.imdbId || item.imdb_id, + 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 + }); + } else { + // For movies or series without specific episode, navigate to main content + navigation.navigate('Streams', { + id: item.id, + type: item.type + }); + } + } catch (error) { + logger.warn('[ContinueWatching] Error handling content press:', error); + // 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 + }); + } else { + navigation.navigate('Streams', { + id: item.id, + type: item.type + }); + } + } }, [navigation]); // Handle long press to delete (moved before renderContinueWatchingItem) @@ -723,7 +801,7 @@ const ContinueWatchingSection = React.forwardRef((props, re } ]} activeOpacity={0.8} - onPress={() => handleContentPress(item.id, item.type)} + onPress={() => handleContentPress(item)} onLongPress={() => handleLongPress(item)} delayLongPress={800} > diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 427cd29..f73284a 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -49,6 +49,7 @@ import { isMkvStream } from '../utils/mkvDetection'; import CustomAlert from '../components/CustomAlert'; import { useToast } from '../contexts/ToastContext'; import { useDownloads } from '../contexts/DownloadsContext'; +import { streamCacheService } from '../services/streamCacheService'; import { PaperProvider } from 'react-native-paper'; const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906'; @@ -1134,6 +1135,26 @@ export const StreamsScreen = () => { // Do NOT pre-force VLC. Let ExoPlayer try first; fallback occurs on decoder error in the player. let forceVlc = !!options?.forceVlc; + // Save stream to cache for future use + try { + const episodeId = (type === 'series' || type === 'other') && selectedEpisode ? selectedEpisode : undefined; + const season = (type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined; + const episode = (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined; + const episodeTitle = (type === 'series' || type === 'other') ? currentEpisode?.name : undefined; + + await streamCacheService.saveStreamToCache( + id, + type, + stream, + metadata, + episodeId, + season, + episode, + episodeTitle + ); + } catch (error) { + logger.warn('[StreamsScreen] Failed to save stream to cache:', error); + } // Show a quick full-screen black overlay to mask rotation flicker // by setting a transient state that renders a covering View (implementation already supported by dark backgrounds) diff --git a/src/services/streamCacheService.ts b/src/services/streamCacheService.ts new file mode 100644 index 0000000..d448711 --- /dev/null +++ b/src/services/streamCacheService.ts @@ -0,0 +1,210 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { logger } from '../utils/logger'; + +export interface CachedStream { + stream: any; // Stream object + metadata: any; // Metadata object + episodeId?: string; // For series episodes + season?: number; + episode?: number; + episodeTitle?: string; + timestamp: number; // When it was cached + url: string; // Stream URL for quick validation +} + +export interface StreamCacheEntry { + cachedStream: CachedStream; + expiresAt: number; // Timestamp when cache expires +} + +const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds +const CACHE_KEY_PREFIX = 'stream_cache_'; + +class StreamCacheService { + /** + * Save a stream to cache + */ + async saveStreamToCache( + id: string, + type: string, + stream: any, + metadata: any, + episodeId?: string, + season?: number, + episode?: number, + episodeTitle?: string + ): Promise { + try { + const cacheKey = this.getCacheKey(id, type, episodeId); + const now = Date.now(); + + const cachedStream: CachedStream = { + stream, + metadata, + episodeId, + season, + episode, + episodeTitle, + timestamp: now, + url: stream.url + }; + + const cacheEntry: StreamCacheEntry = { + cachedStream, + expiresAt: now + CACHE_DURATION + }; + + await AsyncStorage.setItem(cacheKey, JSON.stringify(cacheEntry)); + logger.log(`๐Ÿ’พ [StreamCache] Saved stream cache for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); + logger.log(`๐Ÿ’พ [StreamCache] Cache key: ${cacheKey}`); + logger.log(`๐Ÿ’พ [StreamCache] Stream URL: ${stream.url}`); + logger.log(`๐Ÿ’พ [StreamCache] Expires at: ${new Date(now + CACHE_DURATION).toISOString()}`); + } catch (error) { + logger.warn('[StreamCache] Failed to save stream to cache:', error); + } + } + + /** + * Get cached stream if it exists and is still valid + */ + async getCachedStream(id: string, type: string, episodeId?: string): Promise { + try { + const cacheKey = this.getCacheKey(id, type, episodeId); + logger.log(`๐Ÿ” [StreamCache] Looking for cached stream with key: ${cacheKey}`); + + const cachedData = await AsyncStorage.getItem(cacheKey); + + if (!cachedData) { + logger.log(`โŒ [StreamCache] No cached data found for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); + return null; + } + + const cacheEntry: StreamCacheEntry = JSON.parse(cachedData); + const now = Date.now(); + + logger.log(`๐Ÿ” [StreamCache] Found cached data, expires at: ${new Date(cacheEntry.expiresAt).toISOString()}`); + logger.log(`๐Ÿ” [StreamCache] Current time: ${new Date(now).toISOString()}`); + + // Check if cache has expired + if (now > cacheEntry.expiresAt) { + logger.log(`โฐ [StreamCache] Cache expired for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); + await this.removeCachedStream(id, type, episodeId); + return null; + } + + // Validate that the stream URL is still accessible (quick HEAD request) + logger.log(`๐Ÿ” [StreamCache] Validating stream URL: ${cacheEntry.cachedStream.url}`); + const isUrlValid = await this.validateStreamUrl(cacheEntry.cachedStream.url); + if (!isUrlValid) { + logger.log(`โŒ [StreamCache] Stream URL invalid for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); + await this.removeCachedStream(id, type, episodeId); + return null; + } + + logger.log(`โœ… [StreamCache] Using cached stream for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); + return cacheEntry.cachedStream; + } catch (error) { + logger.warn('[StreamCache] Failed to get cached stream:', error); + return null; + } + } + + /** + * Remove cached stream + */ + async removeCachedStream(id: string, type: string, episodeId?: string): Promise { + try { + const cacheKey = this.getCacheKey(id, type, episodeId); + await AsyncStorage.removeItem(cacheKey); + logger.log(`๐Ÿ—‘๏ธ [StreamCache] Removed cached stream for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); + } catch (error) { + logger.warn('[StreamCache] Failed to remove cached stream:', error); + } + } + + /** + * Clear all cached streams + */ + async clearAllCachedStreams(): Promise { + try { + const allKeys = await AsyncStorage.getAllKeys(); + const cacheKeys = allKeys.filter(key => key.startsWith(CACHE_KEY_PREFIX)); + + for (const key of cacheKeys) { + await storageService.removeItem(key); + } + + logger.log(`๐Ÿงน [StreamCache] Cleared ${cacheKeys.length} cached streams`); + } catch (error) { + logger.warn('[StreamCache] Failed to clear all cached streams:', error); + } + } + + /** + * Get cache key for a specific content item + */ + private getCacheKey(id: string, type: string, episodeId?: string): string { + const baseKey = `${CACHE_KEY_PREFIX}${type}:${id}`; + return episodeId ? `${baseKey}:${episodeId}` : baseKey; + } + + /** + * Validate if a stream URL is still accessible + */ + private async validateStreamUrl(url: string): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); // 3 second timeout + + const response = await fetch(url, { + method: 'HEAD', + signal: controller.signal as any, + } as any); + + clearTimeout(timeout); + return response.ok; + } catch (error) { + return false; + } + } + + /** + * Get cache info for debugging + */ + async getCacheInfo(): Promise<{ totalCached: number; expiredCount: number; validCount: number }> { + try { + const allKeys = await storageService.getAllKeys(); + const cacheKeys = allKeys.filter(key => key.startsWith(CACHE_KEY_PREFIX)); + + let expiredCount = 0; + let validCount = 0; + const now = Date.now(); + + for (const key of cacheKeys) { + try { + const cachedData = await storageService.getItem(key); + if (cachedData) { + const cacheEntry: StreamCacheEntry = JSON.parse(cachedData); + if (now > cacheEntry.expiresAt) { + expiredCount++; + } else { + validCount++; + } + } + } catch (error) { + // Skip invalid entries + } + } + + return { + totalCached: cacheKeys.length, + expiredCount, + validCount + }; + } catch (error) { + return { totalCached: 0, expiredCount: 0, validCount: 0 }; + } + } +} + +export const streamCacheService = new StreamCacheService();