moviecollection fix

This commit is contained in:
tapframe 2025-10-27 14:05:13 +05:30
parent dd542091e1
commit d9fcc085a6
11 changed files with 235 additions and 62 deletions

2
.gitignore vendored
View file

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

View file

@ -4143,7 +4143,10 @@ const AndroidVideoPlayer: React.FC = () => {
<EpisodeStreamsModal
visible={showEpisodeStreamsModal}
episode={selectedEpisodeForStreams}
onClose={() => setShowEpisodeStreamsModal(false)}
onClose={() => {
setShowEpisodeStreamsModal(false);
setShowEpisodesModal(true);
}}
onSelectStream={handleEpisodeStreamSelect}
metadata={metadata ? { id: metadata.id, name: metadata.name } : undefined}
/>

View file

@ -3436,7 +3436,10 @@ const KSPlayerCore: React.FC = () => {
<EpisodeStreamsModal
visible={showEpisodeStreamsModal}
episode={selectedEpisodeForStreams}
onClose={() => setShowEpisodeStreamsModal(false)}
onClose={() => {
setShowEpisodeStreamsModal(false);
setShowEpisodesModal(true);
}}
onSelectStream={handleEpisodeStreamSelect}
metadata={metadata ? { id: metadata.id, name: metadata.name } : undefined}
/>

View file

@ -47,11 +47,12 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
}
});
// 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;

View file

@ -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<string | null>(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);

View file

@ -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<AppSettings>(DEFAULT_SETTINGS);
const [isLoaded, setIsLoaded] = useState<boolean>(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);

View file

@ -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<BackdropItem[]>([]);
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]);

View file

@ -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<string, boolean> | 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<string, boolean> = {};
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<void>)[] = [];
@ -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<number | null>(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

View file

@ -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<string, { value: any; timestamp: number }>();
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<string | null> {
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<void> {
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<void> {
try {
this.storage.clearAll();
this.cache.clear();
} catch (error) {
logger.error('[MMKVStorage] Error clearing storage:', error);
}

View file

@ -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<string, WatchProgress> | 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<Record<string, WatchProgress>> {
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<string, WatchProgress>);
// 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

View file

@ -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<any> {
const cacheKey = this.generateCacheKey(`movie_${movieId}_images_full`);
async getMovieImagesFull(movieId: number | string, language: string = 'en'): Promise<any> {
const cacheKey = this.generateCacheKey(`movie_${movieId}_images_full`, { language });
// Check cache
const cached = this.getCachedData<any>(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<any> {
const cacheKey = this.generateCacheKey(`tv_${showId}_images_full`);
async getTvShowImagesFull(showId: number | string, language: string = 'en'): Promise<any> {
const cacheKey = this.generateCacheKey(`tv_${showId}_images_full`, { language });
// Check cache
const cached = this.getCachedData<any>(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`
}),
});