diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 84876c5..65f837c 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -455,6 +455,17 @@ const AndroidVideoPlayer: React.FC = () => { const videoDuration = data.duration; if (data.duration > 0) { setDuration(videoDuration); + + // Store the actual duration for future reference and update existing progress + if (id && type) { + storageService.setContentDuration(id, type, videoDuration, episodeId); + storageService.updateProgressDuration(id, type, videoDuration, episodeId); + + // Update the saved duration for resume overlay if it was using an estimate + if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) { + setSavedDuration(videoDuration); + } + } } // Set aspect ratio from video dimensions @@ -621,8 +632,6 @@ const AndroidVideoPlayer: React.FC = () => { const handleResume = async () => { if (resumePosition !== null) { - logger.log(`[AndroidVideoPlayer] Resume requested to position: ${resumePosition}s, duration: ${duration}, isPlayerReady: ${isPlayerReady}`); - if (rememberChoice) { try { await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME); @@ -635,11 +644,9 @@ const AndroidVideoPlayer: React.FC = () => { // If video is already loaded and ready, seek immediately if (isPlayerReady && duration > 0 && videoRef.current) { - logger.log(`[AndroidVideoPlayer] Video ready, seeking immediately to: ${resumePosition}s`); seekToTime(resumePosition); } else { // Otherwise, set initial position for when video loads - logger.log(`[AndroidVideoPlayer] Video not ready, setting initial position: ${resumePosition}s`); setInitialPosition(resumePosition); } } diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index df57797..e62d453 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -479,6 +479,17 @@ const VideoPlayer: React.FC = () => { const videoDuration = data.duration / 1000; if (data.duration > 0) { setDuration(videoDuration); + + // Store the actual duration for future reference and update existing progress + if (id && type) { + storageService.setContentDuration(id, type, videoDuration, episodeId); + storageService.updateProgressDuration(id, type, videoDuration, episodeId); + + // Update the saved duration for resume overlay if it was using an estimate + if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) { + setSavedDuration(videoDuration); + } + } } setVideoAspectRatio(data.videoSize.width / data.videoSize.height); @@ -636,8 +647,6 @@ const VideoPlayer: React.FC = () => { const handleResume = async () => { if (resumePosition !== null) { - logger.log(`[VideoPlayer] Resume requested to position: ${resumePosition}s, duration: ${duration}, isPlayerReady: ${isPlayerReady}`); - if (rememberChoice) { try { await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME); @@ -650,11 +659,9 @@ const VideoPlayer: React.FC = () => { // If video is already loaded and ready, seek immediately if (isPlayerReady && duration > 0 && vlcRef.current) { - logger.log(`[VideoPlayer] Video ready, seeking immediately to: ${resumePosition}s`); seekToTime(resumePosition); } else { // Otherwise, set initial position for when video loads - logger.log(`[VideoPlayer] Video not ready, setting initial position: ${resumePosition}s`); setInitialPosition(resumePosition); } } diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 8955525..bdbdafc 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -186,7 +186,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { options.type, true, progressPercent, - options.episodeId + options.episodeId, + currentTime ); logger.log(`[TraktAutosync] Synced progress ${progressPercent.toFixed(1)}%: ${contentData.title}`); @@ -318,7 +319,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { options.type, true, progressPercent, - options.episodeId + options.episodeId, + currentTime ); // Mark session as complete if high progress (scrobbled) diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index 3b61d44..2c1c506 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -324,22 +324,21 @@ export function useTraktIntegration() { // Fetch and merge Trakt progress with local progress const fetchAndMergeTraktProgress = useCallback(async (): Promise => { - logger.log(`[useTraktIntegration] fetchAndMergeTraktProgress called - isAuthenticated: ${isAuthenticated}`); - if (!isAuthenticated) { - logger.log('[useTraktIntegration] Not authenticated, skipping Trakt progress fetch'); return false; } try { // Fetch both playback progress and recently watched movies - logger.log('[useTraktIntegration] Fetching Trakt playback progress and watched movies...'); const [traktProgress, watchedMovies] = await Promise.all([ getTraktPlaybackProgress(), traktService.getWatchedMovies() ]); - logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} Trakt progress items, ${watchedMovies.length} watched movies`); + logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} progress items, ${watchedMovies.length} watched movies`); + + // Batch process all updates to reduce storage notifications + const updatePromises: Promise[] = []; // Process playback progress (in-progress items) for (const item of traktProgress) { @@ -351,27 +350,35 @@ export function useTraktIntegration() { if (item.type === 'movie' && item.movie) { id = item.movie.ids.imdb; type = 'movie'; - logger.log(`[useTraktIntegration] Processing Trakt movie progress: ${item.movie.title} (${id}) - ${item.progress}%`); } else if (item.type === 'episode' && item.show && item.episode) { id = item.show.ids.imdb; type = 'series'; episodeId = `${id}:${item.episode.season}:${item.episode.number}`; - logger.log(`[useTraktIntegration] Processing Trakt episode progress: ${item.show.title} S${item.episode.season}E${item.episode.number} (${id}) - ${item.progress}%`); } else { - logger.warn(`[useTraktIntegration] Skipping invalid Trakt progress item:`, item); continue; } - logger.log(`[useTraktIntegration] Merging progress for ${type} ${id}: ${item.progress}% from ${item.paused_at}`); - await storageService.mergeWithTraktProgress( - id, - type, - item.progress, - item.paused_at, - episodeId + // Try to calculate exact time if we have stored duration + const exactTime = await (async () => { + const storedDuration = await storageService.getContentDuration(id, type, episodeId); + if (storedDuration && storedDuration > 0) { + return (item.progress / 100) * storedDuration; + } + return undefined; + })(); + + updatePromises.push( + storageService.mergeWithTraktProgress( + id, + type, + item.progress, + item.paused_at, + episodeId, + exactTime + ) ); } catch (error) { - logger.error('[useTraktIntegration] Error merging individual Trakt progress:', error); + logger.error('[useTraktIntegration] Error preparing Trakt progress update:', error); } } @@ -381,21 +388,25 @@ export function useTraktIntegration() { if (movie.movie?.ids?.imdb) { const id = movie.movie.ids.imdb; const watchedAt = movie.last_watched_at; - logger.log(`[useTraktIntegration] Processing watched movie: ${movie.movie.title} (${id}) - 100% watched on ${watchedAt}`); - await storageService.mergeWithTraktProgress( - id, - 'movie', - 100, // 100% progress for watched items - watchedAt + updatePromises.push( + storageService.mergeWithTraktProgress( + id, + 'movie', + 100, // 100% progress for watched items + watchedAt + ) ); } } catch (error) { - logger.error('[useTraktIntegration] Error merging watched movie:', error); + logger.error('[useTraktIntegration] Error preparing watched movie update:', error); } } - logger.log(`[useTraktIntegration] Successfully merged ${traktProgress.length} progress items + ${watchedMovies.length} watched movies`); + // Execute all updates in parallel + await Promise.all(updatePromises); + + logger.log(`[useTraktIntegration] Successfully merged ${updatePromises.length} items from Trakt`); return true; } catch (error) { logger.error('[useTraktIntegration] Error fetching and merging Trakt progress:', error); @@ -419,17 +430,10 @@ export function useTraktIntegration() { useEffect(() => { if (isAuthenticated) { // Fetch Trakt progress and merge with local - logger.log('[useTraktIntegration] User authenticated, fetching Trakt progress to replace local data'); fetchAndMergeTraktProgress().then((success) => { if (success) { - logger.log('[useTraktIntegration] Trakt progress merged successfully - local data replaced with Trakt data'); - } else { - logger.warn('[useTraktIntegration] Failed to merge Trakt progress'); + logger.log('[useTraktIntegration] Trakt progress merged successfully'); } - // Small delay to ensure storage subscribers are notified - setTimeout(() => { - logger.log('[useTraktIntegration] Trakt progress merge completed, UI should refresh'); - }, 100); }); } }, [isAuthenticated, fetchAndMergeTraktProgress]); @@ -440,12 +444,7 @@ export function useTraktIntegration() { const handleAppStateChange = (nextAppState: AppStateStatus) => { if (nextAppState === 'active') { - logger.log('[useTraktIntegration] App became active, syncing Trakt data'); - fetchAndMergeTraktProgress().then((success) => { - if (success) { - logger.log('[useTraktIntegration] App focus sync completed successfully'); - } - }).catch(error => { + fetchAndMergeTraktProgress().catch(error => { logger.error('[useTraktIntegration] App focus sync failed:', error); }); } @@ -461,12 +460,7 @@ export function useTraktIntegration() { // Trigger sync when auth status is manually refreshed (for login scenarios) useEffect(() => { if (isAuthenticated) { - logger.log('[useTraktIntegration] Auth status refresh detected, triggering Trakt progress merge'); - fetchAndMergeTraktProgress().then((success) => { - if (success) { - logger.log('[useTraktIntegration] Trakt progress merged after manual auth refresh'); - } - }); + fetchAndMergeTraktProgress(); } }, [lastAuthCheck, isAuthenticated, fetchAndMergeTraktProgress]); diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts index a4ec9d5..63427bc 100644 --- a/src/hooks/useWatchProgress.ts +++ b/src/hooks/useWatchProgress.ts @@ -170,7 +170,6 @@ export const useWatchProgress = ( // Subscribe to storage changes for real-time updates useEffect(() => { const unsubscribe = storageService.subscribeToWatchProgressUpdates(() => { - logger.log('[useWatchProgress] Storage updated, reloading progress'); loadWatchProgress(); }); diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 0878c3e..fc3b4f0 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -13,7 +13,12 @@ interface WatchProgress { class StorageService { private static instance: StorageService; private readonly WATCH_PROGRESS_KEY = '@watch_progress:'; + private readonly CONTENT_DURATION_KEY = '@content_duration:'; private watchProgressSubscribers: (() => void)[] = []; + private notificationDebounceTimer: NodeJS.Timeout | null = null; + private lastNotificationTime: number = 0; + private readonly NOTIFICATION_DEBOUNCE_MS = 1000; // 1 second debounce + private readonly MIN_NOTIFICATION_INTERVAL = 500; // Minimum 500ms between notifications private constructor() {} @@ -25,7 +30,65 @@ class StorageService { } private getWatchProgressKey(id: string, type: string, episodeId?: string): string { - return this.WATCH_PROGRESS_KEY + `${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + return `${this.WATCH_PROGRESS_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + } + + private getContentDurationKey(id: string, type: string, episodeId?: string): string { + return `${this.CONTENT_DURATION_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + } + + public async setContentDuration( + id: string, + type: string, + duration: number, + episodeId?: string + ): Promise { + try { + const key = this.getContentDurationKey(id, type, episodeId); + await AsyncStorage.setItem(key, duration.toString()); + } catch (error) { + logger.error('Error setting content duration:', error); + } + } + + public async getContentDuration( + id: string, + type: string, + episodeId?: string + ): Promise { + try { + const key = this.getContentDurationKey(id, type, episodeId); + const data = await AsyncStorage.getItem(key); + return data ? parseFloat(data) : null; + } catch (error) { + logger.error('Error getting content duration:', error); + return null; + } + } + + public async updateProgressDuration( + id: string, + type: string, + newDuration: number, + episodeId?: string + ): Promise { + try { + const existingProgress = await this.getWatchProgress(id, type, episodeId); + if (existingProgress && Math.abs(existingProgress.duration - newDuration) > 60) { + // Calculate the new current time to maintain the same percentage + const progressPercent = (existingProgress.currentTime / existingProgress.duration) * 100; + const updatedProgress: WatchProgress = { + ...existingProgress, + currentTime: (progressPercent / 100) * newDuration, + duration: newDuration, + lastUpdated: Date.now() + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + logger.log(`[StorageService] Updated progress duration from ${(existingProgress.duration/60).toFixed(0)}min to ${(newDuration/60).toFixed(0)}min`); + } + } catch (error) { + logger.error('Error updating progress duration:', error); + } } public async setWatchProgress( @@ -36,16 +99,56 @@ class StorageService { ): Promise { try { const key = this.getWatchProgressKey(id, type, episodeId); + + // Check if progress has actually changed significantly + const existingProgress = await this.getWatchProgress(id, type, episodeId); + if (existingProgress) { + const timeDiff = Math.abs(progress.currentTime - existingProgress.currentTime); + const durationDiff = Math.abs(progress.duration - existingProgress.duration); + + // Only update if there's a significant change (>5 seconds or duration change) + if (timeDiff < 5 && durationDiff < 1) { + return; // Skip update for minor changes + } + } + await AsyncStorage.setItem(key, JSON.stringify(progress)); - // Notify subscribers - this.notifyWatchProgressSubscribers(); + + // Use debounced notification to reduce spam + this.debouncedNotifySubscribers(); } catch (error) { - logger.error('Error saving watch progress:', error); + logger.error('Error setting watch progress:', error); + } + } + + private debouncedNotifySubscribers(): void { + const now = Date.now(); + + // Clear existing timer + if (this.notificationDebounceTimer) { + clearTimeout(this.notificationDebounceTimer); + } + + // If we notified recently, debounce longer + const timeSinceLastNotification = now - this.lastNotificationTime; + if (timeSinceLastNotification < this.MIN_NOTIFICATION_INTERVAL) { + this.notificationDebounceTimer = setTimeout(() => { + this.notifyWatchProgressSubscribers(); + }, this.NOTIFICATION_DEBOUNCE_MS); + } else { + // Notify immediately if enough time has passed + this.notifyWatchProgressSubscribers(); } } private notifyWatchProgressSubscribers(): void { - this.watchProgressSubscribers.forEach(callback => callback()); + this.lastNotificationTime = Date.now(); + this.notificationDebounceTimer = null; + + // Only notify if we have subscribers + if (this.watchProgressSubscribers.length > 0) { + this.watchProgressSubscribers.forEach(callback => callback()); + } } public subscribeToWatchProgressUpdates(callback: () => void): () => void { @@ -115,7 +218,8 @@ class StorageService { type: string, traktSynced: boolean, traktProgress?: number, - episodeId?: string + episodeId?: string, + exactTime?: number ): Promise { try { const existingProgress = await this.getWatchProgress(id, type, episodeId); @@ -124,7 +228,9 @@ class StorageService { ...existingProgress, traktSynced, traktLastSynced: traktSynced ? Date.now() : existingProgress.traktLastSynced, - traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress + traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress, + // Update current time with exact time if provided + ...(exactTime && exactTime > 0 && { currentTime: exactTime }) }; await this.setWatchProgress(id, type, updatedProgress, episodeId); } @@ -182,60 +288,127 @@ class StorageService { } /** - * Merge Trakt progress with local progress + * Merge Trakt progress with local progress using exact time when available */ public async mergeWithTraktProgress( id: string, type: string, traktProgress: number, traktPausedAt: string, - episodeId?: string + episodeId?: string, + exactTime?: number // Optional exact time in seconds from Trakt scrobble data ): Promise { try { const localProgress = await this.getWatchProgress(id, type, episodeId); const traktTimestamp = new Date(traktPausedAt).getTime(); if (!localProgress) { - // No local progress, use Trakt data (estimate duration) - const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; // Default 1 hour + // No local progress - use stored duration or estimate + let duration = await this.getContentDuration(id, type, episodeId); + let currentTime: number; + + if (exactTime && exactTime > 0) { + // Use exact time from Trakt if available + currentTime = exactTime; + if (!duration) { + // Calculate duration from exact time and percentage + duration = (exactTime / traktProgress) * 100; + } + } else { + // Fallback to percentage-based calculation + if (!duration) { + // Use reasonable duration estimates as fallback + if (type === 'movie') { + duration = 6600; // 110 minutes for movies + } else if (episodeId) { + duration = 2700; // 45 minutes for TV episodes + } else { + duration = 3600; // 60 minutes default + } + } + currentTime = (traktProgress / 100) * duration; + } + const newProgress: WatchProgress = { - currentTime: (traktProgress / 100) * estimatedDuration, - duration: estimatedDuration, + currentTime, + duration, lastUpdated: traktTimestamp, traktSynced: true, traktLastSynced: Date.now(), traktProgress }; await this.setWatchProgress(id, type, newProgress, episodeId); + + const timeSource = exactTime ? 'exact' : 'calculated'; + const durationSource = await this.getContentDuration(id, type, episodeId) ? 'stored' : 'estimated'; + logger.log(`[StorageService] Created progress from Trakt: ${(currentTime/60).toFixed(1)}min (${timeSource}) of ${(duration/60).toFixed(0)}min (${durationSource})`); } else { - // Always prioritize Trakt progress when merging + // Local progress exists - merge intelligently const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100; - if (localProgress.duration > 0) { - // Use Trakt progress, keeping the existing duration - const updatedProgress: WatchProgress = { - ...localProgress, - currentTime: (traktProgress / 100) * localProgress.duration, - lastUpdated: traktTimestamp, - traktSynced: true, - traktLastSynced: Date.now(), - traktProgress - }; - await this.setWatchProgress(id, type, updatedProgress, episodeId); - logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%)`); + // Only proceed if there's a significant difference (>5% or different completion status) + const progressDiff = Math.abs(traktProgress - localProgressPercent); + if (progressDiff < 5 && traktProgress < 100 && localProgressPercent < 100) { + return; // Skip minor updates + } + + let currentTime: number; + let duration = localProgress.duration; + + if (exactTime && exactTime > 0 && localProgress.duration > 0) { + // Use exact time from Trakt, keep local duration + currentTime = exactTime; + + // If exact time doesn't match the duration well, recalculate duration + const calculatedDuration = (exactTime / traktProgress) * 100; + const durationDiff = Math.abs(calculatedDuration - localProgress.duration); + if (durationDiff > 300) { // More than 5 minutes difference + duration = calculatedDuration; + logger.log(`[StorageService] Updated duration based on exact time: ${(localProgress.duration/60).toFixed(0)}min → ${(duration/60).toFixed(0)}min`); + } + } else if (localProgress.duration > 0) { + // Use percentage calculation with local duration + currentTime = (traktProgress / 100) * localProgress.duration; } else { - // If no duration, estimate it from Trakt progress - const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; - const updatedProgress: WatchProgress = { - currentTime: (traktProgress / 100) * estimatedDuration, - duration: estimatedDuration, - lastUpdated: traktTimestamp, - traktSynced: true, - traktLastSynced: Date.now(), - traktProgress - }; - await this.setWatchProgress(id, type, updatedProgress, episodeId); - logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%) - estimated duration`); + // No local duration, check stored duration + const storedDuration = await this.getContentDuration(id, type, episodeId); + duration = storedDuration || 0; + + if (!duration || duration <= 0) { + if (exactTime && exactTime > 0) { + duration = (exactTime / traktProgress) * 100; + currentTime = exactTime; + } else { + // Final fallback to estimates + if (type === 'movie') { + duration = 6600; // 110 minutes for movies + } else if (episodeId) { + duration = 2700; // 45 minutes for TV episodes + } else { + duration = 3600; // 60 minutes default + } + currentTime = (traktProgress / 100) * duration; + } + } else { + currentTime = exactTime && exactTime > 0 ? exactTime : (traktProgress / 100) * duration; + } + } + + const updatedProgress: WatchProgress = { + ...localProgress, + currentTime, + duration, + lastUpdated: traktTimestamp, + traktSynced: true, + traktLastSynced: Date.now(), + traktProgress + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + + // Only log significant changes + if (progressDiff > 10 || traktProgress === 100) { + const timeSource = exactTime ? 'exact' : 'calculated'; + logger.log(`[StorageService] Updated progress: ${(currentTime/60).toFixed(1)}min (${timeSource}) = ${traktProgress}%`); } } } catch (error) {