Implemented caching stream links for faster playback.

This commit is contained in:
tapframe 2025-10-21 14:53:01 +05:30
parent 6c464abdd4
commit 373efa0564
3 changed files with 313 additions and 4 deletions

View file

@ -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<ContinueWatchingRef>((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<ContinueWatchingRef>((props, re
}
]}
activeOpacity={0.8}
onPress={() => handleContentPress(item.id, item.type)}
onPress={() => handleContentPress(item)}
onLongPress={() => handleLongPress(item)}
delayLongPress={800}
>

View file

@ -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)

View file

@ -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<void> {
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<CachedStream | null> {
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<void> {
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<void> {
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<boolean> {
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();