From d9fcc085a66a9a4b6a03c11572b7a1aada6f3971 Mon Sep 17 00:00:00 2001 From: tapframe Date: Mon, 27 Oct 2025 14:05:13 +0530 Subject: [PATCH] moviecollection fix --- .gitignore | 2 + src/components/player/AndroidVideoPlayer.tsx | 5 +- src/components/player/KSPlayerCore.tsx | 5 +- .../player/modals/EpisodesModal.tsx | 5 +- src/hooks/useMetadata.ts | 31 ++++++-- src/hooks/useSettings.ts | 57 ++++++++------ src/screens/BackdropGalleryScreen.tsx | 11 ++- src/screens/HomeScreen.tsx | 74 ++++++++++++++----- src/services/mmkvStorage.ts | 56 +++++++++++++- src/services/storageService.ts | 32 +++++++- src/services/tmdbService.ts | 19 +++-- 11 files changed, 235 insertions(+), 62 deletions(-) diff --git a/.gitignore b/.gitignore index f5201666..1ce445fb 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,5 @@ build-and-publish-app-releases.sh bottomnav.md /TrailerServices mmkv.md +src/services/tmdbService.ts +fix-android-scroll-lag-summary.md diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index a843e4c3..2d06569f 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -4143,7 +4143,10 @@ const AndroidVideoPlayer: React.FC = () => { setShowEpisodeStreamsModal(false)} + onClose={() => { + setShowEpisodeStreamsModal(false); + setShowEpisodesModal(true); + }} onSelectStream={handleEpisodeStreamSelect} metadata={metadata ? { id: metadata.id, name: metadata.name } : undefined} /> diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index e80473cf..46f1f2d6 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -3436,7 +3436,10 @@ const KSPlayerCore: React.FC = () => { setShowEpisodeStreamsModal(false)} + onClose={() => { + setShowEpisodeStreamsModal(false); + setShowEpisodesModal(true); + }} onSelectStream={handleEpisodeStreamSelect} metadata={metadata ? { id: metadata.id, name: metadata.name } : undefined} /> diff --git a/src/components/player/modals/EpisodesModal.tsx b/src/components/player/modals/EpisodesModal.tsx index e48918b3..f994000e 100644 --- a/src/components/player/modals/EpisodesModal.tsx +++ b/src/components/player/modals/EpisodesModal.tsx @@ -47,11 +47,12 @@ export const EpisodesModal: React.FC = ({ } }); + // Initialize season only when modal opens useEffect(() => { - if (currentEpisode?.season) { + if (showEpisodesModal && currentEpisode?.season) { setSelectedSeason(currentEpisode.season); } - }, [currentEpisode]); + }, [showEpisodesModal, currentEpisode?.season]); const loadEpisodesProgress = async () => { if (!metadata?.id) return; diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index b861ebbf..8de18efb 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -954,7 +954,15 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat banner: undefined, // Let useMetadataAssets handle banner via TMDB }; } - setMetadata(finalMetadata); + + // Preserve existing collection if it was set by fetchProductionInfo + setMetadata((prev) => { + const updated = { ...finalMetadata }; + if (prev?.collection) { + updated.collection = prev.collection; + } + return updated; + }); cacheService.setMetadata(id, type, finalMetadata); (async () => { const items = await catalogService.getLibraryItems(); @@ -1907,10 +1915,21 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Fetch TMDB networks/production companies when TMDB ID is available and enrichment is enabled const productionInfoFetchedRef = useRef(null); useEffect(() => { - if (!tmdbId || !settings.enrichMetadataWithTMDB || !metadata) return; + if (!tmdbId || !settings.enrichMetadataWithTMDB || !metadata) { + return; + } const contentKey = `${type}-${tmdbId}`; - if (productionInfoFetchedRef.current === contentKey || (metadata as any).networks) return; + if (productionInfoFetchedRef.current === contentKey) { + return; + } + + // Only skip if networks are set AND collection is already set (for movies) + const hasNetworks = !!(metadata as any).networks; + const hasCollection = !!(metadata as any).collection; + if (hasNetworks && (type !== 'movie' || hasCollection)) { + return; + } const fetchProductionInfo = async () => { try { @@ -2032,7 +2051,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Try to fetch movie images with language parameter try { - const movieImages = await tmdbService.getMovieImagesFull(part.id); + const movieImages = await tmdbService.getMovieImagesFull(part.id, lang); if (movieImages && movieImages.backdrops && movieImages.backdrops.length > 0) { // Filter and sort backdrops by language and quality const languageBackdrops = movieImages.backdrops @@ -2105,12 +2124,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat } } - if (__DEV__) console.log('[useMetadata] Fetched production info via TMDB:', productionInfo); if (productionInfo.length > 0) { - if (__DEV__) console.log('[useMetadata] Setting production info on metadata', { productionInfoCount: productionInfo.length }); setMetadata((prev: any) => ({ ...prev, networks: productionInfo })); - } else { - if (__DEV__) console.log('[useMetadata] No production info found, not setting networks'); } } catch (error) { if (__DEV__) console.error('[useMetadata] Failed to fetch production info:', error); diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 3d465a75..e515b197 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -151,6 +151,11 @@ export const DEFAULT_SETTINGS: AppSettings = { const SETTINGS_STORAGE_KEY = 'app_settings'; +// Singleton settings cache +let cachedSettings: AppSettings | null = null; +let settingsCacheTimestamp = 0; +const SETTINGS_CACHE_TTL = 60000; // 1 minute + export const useSettings = () => { const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [isLoaded, setIsLoaded] = useState(false); @@ -168,41 +173,46 @@ export const useSettings = () => { const loadSettings = async () => { try { + // Use cached settings if available and fresh + const now = Date.now(); + if (cachedSettings && (now - settingsCacheTimestamp) < SETTINGS_CACHE_TTL) { + setSettings(cachedSettings); + setIsLoaded(true); + return; + } + const scope = (await mmkvStorage.getItem('@user:current')) || 'local'; const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`; + + // Use synchronous MMKV reads for better performance const [scopedJson, legacyJson] = await Promise.all([ mmkvStorage.getItem(scopedKey), mmkvStorage.getItem(SETTINGS_STORAGE_KEY), ]); + const parsedScoped = scopedJson ? JSON.parse(scopedJson) : null; const parsedLegacy = legacyJson ? JSON.parse(legacyJson) : null; let merged = parsedScoped || parsedLegacy; - // Fallback: scan any existing user-scoped settings if current scope not set yet + // Simplified fallback - only use getAllKeys if absolutely necessary if (!merged) { - try { - const allKeys = await mmkvStorage.getAllKeys(); - const candidateKeys = (allKeys || []).filter(k => k.endsWith(`:${SETTINGS_STORAGE_KEY}`)); - if (candidateKeys.length > 0) { - const pairs = await mmkvStorage.multiGet(candidateKeys); - for (const [, value] of pairs) { - if (value) { - try { - const candidate = JSON.parse(value); - if (candidate && typeof candidate === 'object') { - merged = candidate; - break; - } - } catch {} - } - } - } - } catch {} + // Use string search on MMKV storage instead of getAllKeys for performance + const scoped = mmkvStorage.getString(scopedKey); + if (scoped) { + try { + merged = JSON.parse(scoped); + } catch {} + } } - if (merged) setSettings({ ...DEFAULT_SETTINGS, ...merged }); - else setSettings(DEFAULT_SETTINGS); + const finalSettings = merged ? { ...DEFAULT_SETTINGS, ...merged } : DEFAULT_SETTINGS; + + // Update cache + cachedSettings = finalSettings; + settingsCacheTimestamp = now; + + setSettings(finalSettings); } catch (error) { if (__DEV__) console.error('Failed to load settings:', error); // Fallback to default settings on error @@ -230,6 +240,11 @@ export const useSettings = () => { ]); // Ensure a current scope exists to avoid future loads missing the chosen scope await mmkvStorage.setItem('@user:current', scope); + + // Update cache + cachedSettings = newSettings; + settingsCacheTimestamp = Date.now(); + setSettings(newSettings); if (__DEV__) console.log(`Setting updated: ${key}`, value); diff --git a/src/screens/BackdropGalleryScreen.tsx b/src/screens/BackdropGalleryScreen.tsx index 5ea099b8..b9b71bb2 100644 --- a/src/screens/BackdropGalleryScreen.tsx +++ b/src/screens/BackdropGalleryScreen.tsx @@ -15,6 +15,7 @@ import FastImage from '@d11/react-native-fast-image'; import { MaterialIcons } from '@expo/vector-icons'; import { TMDBService } from '../services/tmdbService'; import { useTheme } from '../contexts/ThemeContext'; +import { useSettings } from '../hooks/useSettings'; const { width } = Dimensions.get('window'); const BACKDROP_WIDTH = width * 0.9; @@ -39,6 +40,7 @@ const BackdropGalleryScreen: React.FC = () => { const navigation = useNavigation(); const { tmdbId, type, title } = route.params as RouteParams; const { currentTheme } = useTheme(); + const { settings } = useSettings(); const [backdrops, setBackdrops] = useState([]); const [loading, setLoading] = useState(true); @@ -49,12 +51,15 @@ const BackdropGalleryScreen: React.FC = () => { try { setLoading(true); const tmdbService = TMDBService.getInstance(); + + // Get language preference + const language = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'; let images; if (type === 'movie') { - images = await tmdbService.getMovieImagesFull(tmdbId); + images = await tmdbService.getMovieImagesFull(tmdbId, language); } else { - images = await tmdbService.getTvShowImagesFull(tmdbId); + images = await tmdbService.getTvShowImagesFull(tmdbId, language); } if (__DEV__) { @@ -83,7 +88,7 @@ const BackdropGalleryScreen: React.FC = () => { if (tmdbId) { fetchBackdrops(); } - }, [tmdbId, type]); + }, [tmdbId, type, settings.useTmdbLocalizedMetadata, settings.tmdbLanguagePreference]); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index ea0a5942..5628fd59 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -67,6 +67,11 @@ import { HeaderVisibility } from '../contexts/HeaderVisibility'; // Constants const CATALOG_SETTINGS_KEY = 'catalog_settings'; +// In-memory cache for catalog settings to avoid repeated MMKV reads +let cachedCatalogSettings: Record | null = null; +let catalogSettingsCacheTimestamp = 0; +const CATALOG_SETTINGS_CACHE_TTL = 30000; // 30 seconds + // Define interfaces for our data interface Category { id: string; @@ -153,9 +158,24 @@ const HomeScreen = () => { setLoadedCatalogCount(0); try { - const [addons, catalogSettingsJson, addonManifests] = await Promise.all([ + // Check cache first + let catalogSettings: Record = {}; + const now = Date.now(); + + if (cachedCatalogSettings && (now - catalogSettingsCacheTimestamp) < CATALOG_SETTINGS_CACHE_TTL) { + catalogSettings = cachedCatalogSettings; + } else { + // Load from storage + const catalogSettingsJson = await mmkvStorage.getItem(CATALOG_SETTINGS_KEY); + catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {}; + + // Update cache + cachedCatalogSettings = catalogSettings; + catalogSettingsCacheTimestamp = now; + } + + const [addons, addonManifests] = await Promise.all([ catalogService.getAllAddons(), - mmkvStorage.getItem(CATALOG_SETTINGS_KEY), stremioService.getInstalledAddonsAsync() ]); @@ -164,8 +184,6 @@ const HomeScreen = () => { setHasAddons(addons.length > 0); }); - const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {}; - // Create placeholder array with proper order and track indices let catalogIndex = 0; const catalogQueue: (() => Promise)[] = []; @@ -655,6 +673,9 @@ const HomeScreen = () => { // Track scroll direction manually for reliable behavior across platforms const lastScrollYRef = useRef(0); const lastToggleRef = useRef(0); + const scrollAnimationFrameRef = useRef(null); + const isScrollingRef = useRef(false); + const toggleHeader = useCallback((hide: boolean) => { const now = Date.now(); if (now - lastToggleRef.current < 120) return; // debounce @@ -739,21 +760,40 @@ const HomeScreen = () => { ), [catalogsLoading, catalogs, loadedCatalogCount, totalCatalogsRef.current, navigation, currentTheme.colors]); - // Memoize scroll handler to prevent recreating on every render + // Memoize scroll handler with requestAnimationFrame throttling for better performance const handleScroll = useCallback((event: any) => { - const y = event.nativeEvent.contentOffset.y; - const dy = y - lastScrollYRef.current; - lastScrollYRef.current = y; - if (y <= 10) { - toggleHeader(false); - return; - } - // Threshold to avoid jitter - if (dy > 6) { - toggleHeader(true); // scrolling down - } else if (dy < -6) { - toggleHeader(false); // scrolling up + // Persist the event before using requestAnimationFrame to prevent event pooling issues + event.persist(); + + // Cancel any pending animation frame + if (scrollAnimationFrameRef.current !== null) { + cancelAnimationFrame(scrollAnimationFrameRef.current); } + + // Capture scroll values immediately before async operation + const scrollY = event.nativeEvent.contentOffset.y; + + // Use requestAnimationFrame to throttle scroll handling + scrollAnimationFrameRef.current = requestAnimationFrame(() => { + const y = scrollY; + const dy = y - lastScrollYRef.current; + lastScrollYRef.current = y; + + isScrollingRef.current = Math.abs(dy) > 0; + + if (y <= 10) { + toggleHeader(false); + return; + } + // Threshold to avoid jitter + if (dy > 6) { + toggleHeader(true); // scrolling down + } else if (dy < -6) { + toggleHeader(false); // scrolling up + } + + scrollAnimationFrameRef.current = null; + }); }, [toggleHeader]); // Memoize content container style - use stable insets to prevent iOS shifting diff --git a/src/services/mmkvStorage.ts b/src/services/mmkvStorage.ts index 02465e30..daab954a 100644 --- a/src/services/mmkvStorage.ts +++ b/src/services/mmkvStorage.ts @@ -4,6 +4,10 @@ import { logger } from '../utils/logger'; class MMKVStorage { private static instance: MMKVStorage; private storage = createMMKV(); + // In-memory cache for frequently accessed data + private cache = new Map(); + private readonly CACHE_TTL = 30000; // 30 seconds + private readonly MAX_CACHE_SIZE = 100; // Limit cache size to prevent memory issues private constructor() {} @@ -14,11 +18,56 @@ class MMKVStorage { return MMKVStorage.instance; } + // Cache management methods + private getCached(key: string): string | null { + const cached = this.cache.get(key); + if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { + return cached.value; + } + if (cached) { + this.cache.delete(key); + } + return null; + } + + private setCached(key: string, value: any): void { + // Implement LRU-style eviction if cache is too large + if (this.cache.size >= this.MAX_CACHE_SIZE) { + const firstKey = this.cache.keys().next().value; + if (firstKey) { + this.cache.delete(firstKey); + } + } + this.cache.set(key, { value, timestamp: Date.now() }); + } + + private invalidateCache(key?: string): void { + if (key) { + this.cache.delete(key); + } else { + this.cache.clear(); + } + } + // AsyncStorage-compatible API async getItem(key: string): Promise { try { + // Check cache first + const cached = this.getCached(key); + if (cached !== null) { + return cached; + } + + // Read from storage const value = this.storage.getString(key); - return value ?? null; + const result = value ?? null; + + // Cache the result + if (result !== null) { + this.setCached(key, result); + } + + return result; } catch (error) { logger.error(`[MMKVStorage] Error getting item ${key}:`, error); return null; @@ -28,6 +77,8 @@ class MMKVStorage { async setItem(key: string, value: string): Promise { try { this.storage.set(key, value); + // Update cache immediately + this.setCached(key, value); } catch (error) { logger.error(`[MMKVStorage] Error setting item ${key}:`, error); } @@ -39,6 +90,8 @@ class MMKVStorage { if (this.storage.contains(key)) { this.storage.remove(key); } + // Invalidate cache + this.invalidateCache(key); } catch (error) { logger.error(`[MMKVStorage] Error removing item ${key}:`, error); } @@ -71,6 +124,7 @@ class MMKVStorage { async clear(): Promise { try { this.storage.clearAll(); + this.cache.clear(); } catch (error) { logger.error('[MMKVStorage] Error clearing storage:', error); } diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 07771099..4c7b3603 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -24,6 +24,11 @@ class StorageService { private readonly NOTIFICATION_DEBOUNCE_MS = 1000; // 1 second debounce private readonly MIN_NOTIFICATION_INTERVAL = 500; // Minimum 500ms between notifications + // Cache for getAllWatchProgress + private watchProgressCache: Record | null = null; + private watchProgressCacheTimestamp = 0; + private readonly WATCH_PROGRESS_CACHE_TTL = 5000; // 5 seconds + private constructor() {} public static getInstance(): StorageService { @@ -263,6 +268,9 @@ class StorageService { : Date.now(); const updated = { ...progress, lastUpdated: timestamp }; await mmkvStorage.setItem(key, JSON.stringify(updated)); + + // Invalidate cache + this.invalidateWatchProgressCache(); // Notify subscribers; allow forcing immediate notification if (options?.forceNotify) { @@ -349,6 +357,10 @@ class StorageService { const key = await this.getWatchProgressKeyScoped(id, type, episodeId); await mmkvStorage.removeItem(key); await this.addWatchProgressTombstone(id, type, episodeId); + + // Invalidate cache + this.invalidateWatchProgressCache(); + // Notify subscribers this.notifyWatchProgressSubscribers(); // Emit explicit remove event for sync layer @@ -360,22 +372,40 @@ class StorageService { public async getAllWatchProgress(): Promise> { try { + // Use cache if available and fresh + const now = Date.now(); + if (this.watchProgressCache && (now - this.watchProgressCacheTimestamp) < this.WATCH_PROGRESS_CACHE_TTL) { + return this.watchProgressCache; + } + const scope = await this.getUserScope(); const prefix = `@user:${scope}:${this.WATCH_PROGRESS_KEY}`; const keys = await mmkvStorage.getAllKeys(); const watchProgressKeys = keys.filter(key => key.startsWith(prefix)); const pairs = await mmkvStorage.multiGet(watchProgressKeys); - return pairs.reduce((acc, [key, value]) => { + + const result = pairs.reduce((acc, [key, value]) => { if (value) { acc[key.replace(prefix, '')] = JSON.parse(value); } return acc; }, {} as Record); + + // Update cache + this.watchProgressCache = result; + this.watchProgressCacheTimestamp = now; + + return result; } catch (error) { logger.error('Error getting all watch progress:', error); return {}; } } + + private invalidateWatchProgressCache(): void { + this.watchProgressCache = null; + this.watchProgressCacheTimestamp = 0; + } /** * Update Trakt sync status for a watch progress entry diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index 9f3f4520..c0fc4529 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -906,22 +906,27 @@ export class TMDBService { /** * Get movie images (logos, posters, backdrops) by TMDB ID - returns full images object */ - async getMovieImagesFull(movieId: number | string): Promise { - const cacheKey = this.generateCacheKey(`movie_${movieId}_images_full`); + async getMovieImagesFull(movieId: number | string, language: string = 'en'): Promise { + const cacheKey = this.generateCacheKey(`movie_${movieId}_images_full`, { language }); // Check cache const cached = this.getCachedData(cacheKey); - if (cached !== null) return cached; + if (cached !== null) { + return cached; + } + try { const response = await axios.get(`${BASE_URL}/movie/${movieId}/images`, { headers: await this.getHeaders(), params: await this.getParams({ - include_image_language: `en,null` + include_image_language: `${language},en,null` }), }); const data = response.data; + + this.setCachedData(cacheKey, data); return data; } catch (error) { @@ -1056,8 +1061,8 @@ export class TMDBService { /** * Get TV show images (logos, posters, backdrops) by TMDB ID - returns full images object */ - async getTvShowImagesFull(showId: number | string): Promise { - const cacheKey = this.generateCacheKey(`tv_${showId}_images_full`); + async getTvShowImagesFull(showId: number | string, language: string = 'en'): Promise { + const cacheKey = this.generateCacheKey(`tv_${showId}_images_full`, { language }); // Check cache const cached = this.getCachedData(cacheKey); @@ -1067,7 +1072,7 @@ export class TMDBService { const response = await axios.get(`${BASE_URL}/tv/${showId}/images`, { headers: await this.getHeaders(), params: await this.getParams({ - include_image_language: `en,null` + include_image_language: `${language},en,null` }), });