removed aggressive cache cleaning

This commit is contained in:
tapframe 2025-10-12 00:27:30 +05:30
parent 714226b6a5
commit 8178dfc215
7 changed files with 38 additions and 389 deletions

View file

@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, StyleSheet, Dimensions } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { imageCacheService } from '../../services/imageCacheService';
import FastImage from '@d11/react-native-fast-image';
import { logger } from '../../utils/logger';
interface OptimizedImageProps {
@ -59,16 +58,14 @@ const OptimizedImage: React.FC<OptimizedImageProps> = ({
onLoad,
onError,
contentFit = 'cover',
transition = 200,
transition = 0,
cachePolicy = 'memory'
}) => {
const [isLoaded, setIsLoaded] = useState(false);
const [hasError, setHasError] = useState(false);
const [isVisible, setIsVisible] = useState(!lazy);
const [recyclingKey] = useState(() => `${Math.random().toString(36).slice(2)}-${Date.now()}`);
const [optimizedUrl, setOptimizedUrl] = useState<string>('');
const mountedRef = useRef(true);
const loadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Extract URL from source
const sourceUrl = typeof source === 'string' ? source : source?.uri || '';
@ -80,9 +77,6 @@ const OptimizedImage: React.FC<OptimizedImageProps> = ({
useEffect(() => {
return () => {
mountedRef.current = false;
if (loadTimeoutRef.current) {
clearTimeout(loadTimeoutRef.current);
}
};
}, []);
@ -97,7 +91,6 @@ const OptimizedImage: React.FC<OptimizedImageProps> = ({
// Lazy loading intersection observer simulation
useEffect(() => {
if (lazy && !isVisible) {
// Simple lazy loading - load after a short delay to simulate intersection
const timer = setTimeout(() => {
if (mountedRef.current) {
setIsVisible(true);
@ -108,41 +101,22 @@ const OptimizedImage: React.FC<OptimizedImageProps> = ({
}
}, [lazy, isVisible, priority]);
// Preload image with caching
// Preload image via FastImage
const preloadImage = useCallback(async () => {
if (!optimizedUrl || !isVisible) return;
try {
// Use our cache service to manage the image
const cachedUrl = await imageCacheService.getCachedImageUrl(optimizedUrl);
// Set a timeout for loading
loadTimeoutRef.current = setTimeout(() => {
if (mountedRef.current && !isLoaded) {
logger.warn(`[OptimizedImage] Load timeout for: ${optimizedUrl.substring(0, 50)}...`);
setHasError(true);
}
}, 10000); // 10 second timeout
// Skip prefetch to reduce memory pressure and heating
// await ExpoImage.prefetch(cachedUrl);
if (mountedRef.current) {
setIsLoaded(true);
if (loadTimeoutRef.current) {
clearTimeout(loadTimeoutRef.current);
loadTimeoutRef.current = null;
}
onLoad?.();
}
await FastImage.preload([{ uri: optimizedUrl }]);
if (!mountedRef.current) return;
setIsLoaded(true);
onLoad?.();
} catch (error) {
if (mountedRef.current) {
logger.error(`[OptimizedImage] Failed to load: ${optimizedUrl.substring(0, 50)}...`, error);
setHasError(true);
onError?.(error);
}
if (!mountedRef.current) return;
logger.error(`[OptimizedImage] Failed to preload: ${optimizedUrl.substring(0, 50)}...`, error);
setHasError(true);
onError?.(error);
}
}, [optimizedUrl, isVisible, isLoaded, onLoad, onError]);
}, [optimizedUrl, isVisible, onLoad, onError]);
useEffect(() => {
if (isVisible && optimizedUrl && !isLoaded && !hasError) {
@ -158,26 +132,23 @@ const OptimizedImage: React.FC<OptimizedImageProps> = ({
// Show placeholder while loading or on error
if (!isLoaded || hasError) {
return (
<ExpoImage
<FastImage
source={{ uri: placeholder }}
style={style}
contentFit={contentFit}
transition={0}
cachePolicy="memory"
resizeMode={FastImage.resizeMode.cover}
/>
);
}
return (
<ExpoImage
source={{ uri: optimizedUrl }}
<FastImage
source={{
uri: optimizedUrl,
priority: priority === 'high' ? FastImage.priority.high : priority === 'low' ? FastImage.priority.low : FastImage.priority.normal,
cache: FastImage.cacheControl.immutable
}}
style={style}
contentFit={contentFit}
transition={transition}
cachePolicy={cachePolicy}
// Use a stable recycling key per component instance to keep textures alive between reuses
// This mitigates flicker on fast horizontal scrolls
recyclingKey={recyclingKey}
resizeMode={contentFit === 'contain' ? FastImage.resizeMode.contain : contentFit === 'cover' ? FastImage.resizeMode.cover : FastImage.resizeMode.cover}
onLoad={() => {
setIsLoaded(true);
onLoad?.();

View file

@ -87,6 +87,12 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
const [menuVisible, setMenuVisible] = useState(false);
const [isWatched, setIsWatched] = useState(false);
const [imageError, setImageError] = useState(false);
useEffect(() => {
// Reset image error state when item changes, allowing for retry on re-render
setImageError(false);
}, [item.id, item.poster]);
const { currentTheme } = useTheme();
const { settings, isLoaded } = useSettings();
const posterRadius = typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12;
@ -245,6 +251,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
}}
style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, borderRadius: posterRadius }]}
resizeMode={FastImage.resizeMode.cover}
onLoad={() => {
setImageError(false);
}}
onError={() => {
if (__DEV__) console.warn('Image load error for:', item.poster);
setImageError(true);
@ -359,6 +368,8 @@ const styles = StyleSheet.create({
});
export default React.memo(ContentItem, (prev, next) => {
// Only re-render when the item ID changes (FastImage handles caching internally)
return prev.item.id === next.item.id && prev.item.type === next.item.type;
// Re-render when identity or poster changes. Caching is handled by FastImage.
if (prev.item.id !== next.item.id) return false;
if (prev.item.poster !== next.item.poster) return false;
return true;
});

View file

@ -32,7 +32,6 @@ import { useSettings } from '../../hooks/useSettings';
import { TMDBService } from '../../services/tmdbService';
import { logger } from '../../utils/logger';
import { useTheme } from '../../contexts/ThemeContext';
import { imageCacheService } from '../../services/imageCacheService';
interface FeaturedContentProps {
featuredContent: StreamingContent | null;
@ -234,7 +233,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
});
await Promise.race([
imageCacheService.getCachedImageUrl(url),
FastImage.preload([{ uri: url }]),
timeout,
]);
imageCache[url] = true;

View file

@ -61,7 +61,6 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Toast } from 'toastify-react-native';
import FirstTimeWelcome from '../components/FirstTimeWelcome';
import { imageCacheService } from '../services/imageCacheService';
import { HeaderVisibility } from '../contexts/HeaderVisibility';
// Constants

View file

@ -94,7 +94,7 @@ const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item:
let isMounted = true;
const fetchPoster = async () => {
if (item.images) {
const url = await TraktService.getTraktPosterUrlCached(item.images);
const url = TraktService.getTraktPosterUrl(item.images);
if (isMounted && url) {
setPosterUrl(url);
}

View file

@ -1,291 +0,0 @@
import { logger } from '../utils/logger';
import FastImage from '@d11/react-native-fast-image';
import { AppState, AppStateStatus } from 'react-native';
interface CachedImage {
url: string;
localPath: string;
timestamp: number;
expiresAt: number;
size?: number; // Track approximate memory usage
accessCount: number; // Track usage frequency
lastAccessed: number; // Track last access time
}
class ImageCacheService {
private cache = new Map<string, CachedImage>();
private readonly CACHE_DURATION = Infinity; // Session-only: valid until app close
private readonly MAX_CACHE_SIZE = 25; // Further reduced maximum number of cached images
private readonly MAX_MEMORY_MB = 40; // Further reduced maximum memory usage in MB
private currentMemoryUsage = 0;
private cleanupInterval: NodeJS.Timeout | null = null;
private appStateSubscription: any = null;
constructor() {
// Start cleanup interval every 15 minutes (more frequent cleanup to reduce memory pressure)
this.cleanupInterval = setInterval(() => {
this.performCleanup();
}, 15 * 60 * 1000);
// Reduce memory footprint when app goes to background
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
}
/**
* Get a cached image URL or cache the original if not present
*/
public async getCachedImageUrl(originalUrl: string): Promise<string> {
if (!originalUrl || originalUrl.includes('placeholder')) {
return originalUrl; // Don't cache placeholder images
}
// Check if we have a valid cached version
const cached = this.cache.get(originalUrl);
if (cached && cached.expiresAt > Date.now()) {
// Update access tracking
cached.accessCount++;
cached.lastAccessed = Date.now();
// Skip verbose logging to reduce CPU load
return cached.localPath;
}
// Check memory pressure before adding new entries (more lenient)
if (this.cache.size >= this.MAX_CACHE_SIZE * 0.95) {
// Skip verbose logging to reduce CPU load
return originalUrl;
}
try {
// Estimate image size (rough approximation)
const estimatedSize = this.estimateImageSize(originalUrl);
const cachedImage: CachedImage = {
url: originalUrl,
localPath: originalUrl, // In production, this would be a local file path
timestamp: Date.now(),
expiresAt: Date.now() + this.CACHE_DURATION,
size: estimatedSize,
accessCount: 1,
lastAccessed: Date.now()
};
this.cache.set(originalUrl, cachedImage);
this.currentMemoryUsage += estimatedSize;
this.enforceMemoryLimits();
// Skip verbose logging to reduce CPU load
return cachedImage.localPath;
} catch (error) {
logger.error('[ImageCache] Failed to cache image:', error);
return originalUrl; // Fallback to original URL
}
}
/**
* Check if an image is cached
*/
public isCached(url: string): boolean {
const cached = this.cache.get(url);
return cached !== undefined && cached.expiresAt > Date.now();
}
/**
* Log cache status (for debugging)
*/
public logCacheStatus(): void {
const stats = this.getCacheStats();
logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`);
// Log first 5 cached URLs for debugging
const entries = Array.from(this.cache.entries()).slice(0, 5);
entries.forEach(([url, cached]) => {
const isExpired = cached.expiresAt <= Date.now();
const timeLeft = Math.max(0, cached.expiresAt - Date.now()) / 1000 / 60; // minutes
logger.log(`[ImageCache] - ${url.substring(0, 60)}... (${isExpired ? 'EXPIRED' : `${timeLeft.toFixed(1)}m left`})`);
});
}
/**
* Clear expired cache entries
*/
public clearExpiredCache(): void {
const now = Date.now();
for (const [url, cached] of this.cache.entries()) {
if (cached.expiresAt <= now) {
this.cache.delete(url);
}
}
}
/**
* Clear all cached images
*/
public clearAllCache(): void {
this.cache.clear();
logger.log('[ImageCache] Cleared all cached images');
}
/**
* Get cache statistics
*/
public getCacheStats(): { size: number; expired: number } {
const now = Date.now();
let expired = 0;
for (const cached of this.cache.values()) {
if (cached.expiresAt <= now) {
expired++;
}
}
return {
size: this.cache.size,
expired,
};
}
/**
* Enforce maximum cache size by removing oldest entries
*/
private enforceMaxCacheSize(): void {
if (this.cache.size <= this.MAX_CACHE_SIZE) {
return;
}
// Convert to array and sort by timestamp (oldest first)
const entries = Array.from(this.cache.entries()).sort(
(a, b) => a[1].timestamp - b[1].timestamp
);
// Remove oldest entries
const toRemove = this.cache.size - this.MAX_CACHE_SIZE;
for (let i = 0; i < toRemove; i++) {
this.cache.delete(entries[i][0]);
}
logger.log(`[ImageCache] Removed ${toRemove} old entries to enforce cache size limit`);
}
/**
* Enforce memory limits using LRU eviction
*/
private enforceMemoryLimits(): void {
const maxMemoryBytes = this.MAX_MEMORY_MB * 1024 * 1024;
if (this.currentMemoryUsage <= maxMemoryBytes) {
return;
}
// Sort by access frequency and recency (LRU)
const entries = Array.from(this.cache.entries()).sort((a, b) => {
const scoreA = a[1].accessCount * 0.3 + (Date.now() - a[1].lastAccessed) * 0.7;
const scoreB = b[1].accessCount * 0.3 + (Date.now() - b[1].lastAccessed) * 0.7;
return scoreB - scoreA; // Higher score = more likely to be evicted
});
let removedCount = 0;
for (const [url, cached] of entries) {
if (this.currentMemoryUsage <= maxMemoryBytes * 0.8) { // Leave 20% buffer
break;
}
this.cache.delete(url);
this.currentMemoryUsage -= cached.size || 0;
removedCount++;
}
// Skip verbose memory eviction logging to reduce CPU load
}
/**
* Estimate image size based on URL patterns
*/
private estimateImageSize(url: string): number {
// Rough estimates in bytes based on common image types
if (url.includes('poster')) return 150 * 1024; // 150KB for posters
if (url.includes('banner') || url.includes('backdrop')) return 300 * 1024; // 300KB for banners
if (url.includes('logo')) return 50 * 1024; // 50KB for logos
if (url.includes('thumb')) return 75 * 1024; // 75KB for thumbnails
return 200 * 1024; // Default 200KB
}
/**
* Check if we should skip caching due to memory pressure
*/
private shouldSkipCaching(): boolean {
const maxMemoryBytes = this.MAX_MEMORY_MB * 1024 * 1024;
return this.currentMemoryUsage > maxMemoryBytes * 0.9 || this.cache.size >= this.MAX_CACHE_SIZE;
}
/**
* Perform comprehensive cleanup
*/
private performCleanup(): void {
const initialSize = this.cache.size;
const initialMemory = this.currentMemoryUsage;
// Remove expired entries
this.clearExpiredCache();
// Recalculate memory usage
this.recalculateMemoryUsage();
// Enforce limits
this.enforceMemoryLimits();
this.enforceMaxCacheSize();
// Avoid clearing Expo's global memory cache to prevent re-decode churn
const finalSize = this.cache.size;
const finalMemory = this.currentMemoryUsage;
// Skip verbose cleanup logging to reduce CPU load
}
/**
* Recalculate memory usage from cache entries
*/
private recalculateMemoryUsage(): void {
this.currentMemoryUsage = 0;
for (const cached of this.cache.values()) {
this.currentMemoryUsage += cached.size || 0;
}
}
/**
* Cleanup resources
*/
public destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
if (this.appStateSubscription) {
this.appStateSubscription.remove();
this.appStateSubscription = null;
}
this.clearAllCache();
}
private handleAppStateChange = (nextState: AppStateStatus) => {
if (nextState !== 'active') {
// On background/inactive, aggressively trim cache to 25% to reduce memory pressure
const targetSize = Math.floor(this.MAX_CACHE_SIZE * 0.25);
if (this.cache.size > targetSize) {
const entries = Array.from(this.cache.entries());
const toRemove = this.cache.size - targetSize;
for (let i = 0; i < toRemove; i++) {
const [url, cached] = entries[i];
this.cache.delete(url);
this.currentMemoryUsage -= cached.size || 0;
}
}
// Force aggressive memory cleanup
this.enforceMemoryLimits();
// Clear any remaining memory pressure
this.currentMemoryUsage = Math.min(this.currentMemoryUsage, this.MAX_MEMORY_MB * 1024 * 1024 * 0.3);
}
};
}
export const imageCacheService = new ImageCacheService();

View file

@ -1176,7 +1176,7 @@ export class TraktService {
}
/**
* Extract poster URL from Trakt images with basic caching
* Extract poster URL from Trakt images
*/
public static getTraktPosterUrl(images?: TraktImages): string | null {
if (!images || !images.poster || images.poster.length === 0) {
@ -1185,36 +1185,7 @@ export class TraktService {
// Get the first poster and add https prefix
const posterPath = images.poster[0];
const fullUrl = posterPath.startsWith('http') ? posterPath : `https://${posterPath}`;
// Try to use cached version synchronously (basic cache check)
const isCached = imageCacheService.isCached(fullUrl);
if (isCached) {
logger.log(`[TraktService] 🎯 Using cached poster: ${fullUrl.substring(0, 60)}...`);
} else {
logger.log(`[TraktService] 📥 New poster URL: ${fullUrl.substring(0, 60)}...`);
// Queue for async caching
imageCacheService.getCachedImageUrl(fullUrl).catch(error => {
logger.error('[TraktService] Background caching failed:', error);
});
}
return fullUrl;
}
/**
* Extract poster URL from Trakt images with async caching
*/
public static async getTraktPosterUrlCached(images?: TraktImages): Promise<string | null> {
const url = this.getTraktPosterUrl(images);
if (!url) return null;
try {
return await imageCacheService.getCachedImageUrl(url);
} catch (error) {
logger.error('[TraktService] Failed to cache image:', error);
return url;
}
return posterPath.startsWith('http') ? posterPath : `https://${posterPath}`;
}
/**
@ -2029,17 +2000,6 @@ export class TraktService {
logger.error('[TraktService] DEBUG: Error fetching playback progress:', error);
}
}
/**
* Debug image cache status
*/
public static debugImageCache(): void {
try {
logger.log('[TraktService] === IMAGE CACHE DEBUG ===');
imageCacheService.logCacheStatus();
} catch (error) {
logger.error('[TraktService] Debug image cache failed:', error);
}
}
/**
* Delete a playback progress entry on Trakt by its playback `id`.