mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-28 20:03:34 +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,
|
Dimensions,
|
||||||
AppState,
|
AppState,
|
||||||
AppStateStatus,
|
AppStateStatus,
|
||||||
ActivityIndicator
|
ActivityIndicator,
|
||||||
|
Platform
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { FlashList } from '@shopify/flash-list';
|
import { FlashList } from '@shopify/flash-list';
|
||||||
import Animated, { FadeIn, Layout } from 'react-native-reanimated';
|
import Animated, { FadeIn, Layout } from 'react-native-reanimated';
|
||||||
|
|
@ -23,6 +24,7 @@ import { logger } from '../../utils/logger';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { TraktService } from '../../services/traktService';
|
import { TraktService } from '../../services/traktService';
|
||||||
import { stremioService } from '../../services/stremioService';
|
import { stremioService } from '../../services/stremioService';
|
||||||
|
import { streamCacheService } from '../../services/streamCacheService';
|
||||||
import CustomAlert from '../../components/CustomAlert';
|
import CustomAlert from '../../components/CustomAlert';
|
||||||
|
|
||||||
// Define interface for continue watching items
|
// Define interface for continue watching items
|
||||||
|
|
@ -650,8 +652,84 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleContentPress = useCallback((id: string, type: string) => {
|
const handleContentPress = useCallback(async (item: ContinueWatchingItem) => {
|
||||||
navigation.navigate('Metadata', { id, type });
|
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]);
|
}, [navigation]);
|
||||||
|
|
||||||
// Handle long press to delete (moved before renderContinueWatchingItem)
|
// Handle long press to delete (moved before renderContinueWatchingItem)
|
||||||
|
|
@ -723,7 +801,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
onPress={() => handleContentPress(item.id, item.type)}
|
onPress={() => handleContentPress(item)}
|
||||||
onLongPress={() => handleLongPress(item)}
|
onLongPress={() => handleLongPress(item)}
|
||||||
delayLongPress={800}
|
delayLongPress={800}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import { isMkvStream } from '../utils/mkvDetection';
|
||||||
import CustomAlert from '../components/CustomAlert';
|
import CustomAlert from '../components/CustomAlert';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useDownloads } from '../contexts/DownloadsContext';
|
import { useDownloads } from '../contexts/DownloadsContext';
|
||||||
|
import { streamCacheService } from '../services/streamCacheService';
|
||||||
import { PaperProvider } from 'react-native-paper';
|
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';
|
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.
|
// Do NOT pre-force VLC. Let ExoPlayer try first; fallback occurs on decoder error in the player.
|
||||||
let forceVlc = !!options?.forceVlc;
|
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
|
// 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)
|
// 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