mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
Implemented caching stream links for faster playback.
This commit is contained in:
parent
6c464abdd4
commit
373efa0564
3 changed files with 313 additions and 4 deletions
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
210
src/services/streamCacheService.ts
Normal file
210
src/services/streamCacheService.ts
Normal 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();
|
||||
Loading…
Reference in a new issue