diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 203efa19..08b6077d 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1364,12 +1364,12 @@ const AndroidVideoPlayer: React.FC = () => { const backgroundSync = async () => { try { logger.log('[AndroidVideoPlayer] Starting background Trakt sync'); - // Force one last progress update (scrobble/pause) with the exact time + // IMMEDIATE: Force immediate progress update (scrobble/pause) with the exact time await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true); - - // Sync progress to Trakt - await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount'); - + + // IMMEDIATE: Use user_close reason to trigger immediate scrobble stop + await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'user_close'); + logger.log('[AndroidVideoPlayer] Background Trakt sync completed successfully'); } catch (error) { logger.error('[AndroidVideoPlayer] Error in background Trakt sync:', error); @@ -1756,15 +1756,14 @@ const AndroidVideoPlayer: React.FC = () => { setCurrentTime(finalTime); try { - // Force one last progress update (scrobble/pause) with the exact final time + // REGULAR: Use regular sync for natural video end (not immediate since it's not user-triggered) logger.log('[AndroidVideoPlayer] Video ended naturally, sending final progress update with 100%'); - await traktAutosync.handleProgressUpdate(finalTime, duration, true); - - // IMMEDIATE SYNC: Remove delay for instant sync - // Now send the stop call immediately + await traktAutosync.handleProgressUpdate(finalTime, duration, false); // force=false for regular sync + + // REGULAR: Use 'ended' reason for natural video end (uses regular queued method) logger.log('[AndroidVideoPlayer] Sending final stop call after natural end'); await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended'); - + logger.log('[AndroidVideoPlayer] Completed video end sync to Trakt'); } catch (error) { logger.error('[AndroidVideoPlayer] Error syncing to Trakt on video end:', error); @@ -2048,10 +2047,10 @@ const AndroidVideoPlayer: React.FC = () => { if (videoRef.current) { const newPausedState = !paused; setPaused(newPausedState); - - // Send a forced pause update to Trakt immediately when user pauses - if (newPausedState && duration > 0) { - traktAutosync.handleProgressUpdate(currentTime, duration, true); + + // IMMEDIATE: Send immediate progress update to Trakt for both pause and unpause + if (duration > 0) { + traktAutosync.handleProgressUpdate(currentTime, duration, true); // force=true triggers immediate sync } } }; diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 8db2e8c1..6c052385 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -799,9 +799,9 @@ const VideoPlayer: React.FC = () => { if (isMounted.current) { setPaused(true); - // Send a forced pause update to Trakt immediately when user pauses + // IMMEDIATE: Send immediate pause update to Trakt when user pauses if (duration > 0) { - traktAutosync.handleProgressUpdate(currentTime, duration, true); + traktAutosync.handleProgressUpdate(currentTime, duration, true); // force=true triggers immediate sync } } }; @@ -1331,12 +1331,12 @@ const VideoPlayer: React.FC = () => { const backgroundSync = async () => { try { logger.log('[VideoPlayer] Starting background Trakt sync'); - // Force one last progress update (scrobble/pause) with the exact time + // IMMEDIATE: Force immediate progress update (scrobble/pause) with the exact time await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true); - - // Sync progress to Trakt - await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount'); - + + // IMMEDIATE: Use user_close reason to trigger immediate scrobble stop + await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'user_close'); + logger.log('[VideoPlayer] Background Trakt sync completed successfully'); } catch (error) { logger.error('[VideoPlayer] Error in background Trakt sync:', error); @@ -1543,12 +1543,11 @@ const VideoPlayer: React.FC = () => { setCurrentTime(finalTime); try { - // Force one last progress update (scrobble/pause) with the exact final time + // REGULAR: Use regular sync for natural video end (not immediate since it's not user-triggered) logger.log('[VideoPlayer] Video ended naturally, sending final progress update with 100%'); - await traktAutosync.handleProgressUpdate(finalTime, duration, true); + await traktAutosync.handleProgressUpdate(finalTime, duration, false); // force=false for regular sync - // IMMEDIATE SYNC: Remove delay for instant sync - // Now send the stop call immediately + // REGULAR: Use 'ended' reason for natural video end (uses regular queued method) logger.log('[VideoPlayer] Sending final stop call after natural end'); await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended'); diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 2ae8d4aa..b6d300ae 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -21,11 +21,13 @@ interface TraktAutosyncOptions { } export function useTraktAutosync(options: TraktAutosyncOptions) { - const { - isAuthenticated, - startWatching, + const { + isAuthenticated, + startWatching, updateProgress, - stopWatching + updateProgressImmediate, + stopWatching, + stopWatchingImmediate } = useTraktIntegration(); const { settings: autosyncSettings } = useTraktAutosyncSettings(); @@ -148,8 +150,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Sync progress during playback const handleProgressUpdate = useCallback(async ( - currentTime: number, - duration: number, + currentTime: number, + duration: number, force: boolean = false ) => { if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) { @@ -164,45 +166,72 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { try { const progressPercent = (currentTime / duration) * 100; const now = Date.now(); - - // IMMEDIATE SYNC: Remove all debouncing and frequency checks for instant sync - const progressDiff = Math.abs(progressPercent - lastSyncProgress.current); - - // Only skip if not forced and progress difference is minimal (< 1%) - if (!force && progressDiff < 1) { - return; - } - const contentData = buildContentData(); - const success = await updateProgress(contentData, progressPercent, force); - - if (success) { - lastSyncTime.current = now; - lastSyncProgress.current = progressPercent; - - // Update local storage sync status - await storageService.updateTraktSyncStatus( - options.id, - options.type, - true, - progressPercent, - options.episodeId, - currentTime - ); - - // Progress sync logging removed + // IMMEDIATE SYNC: Use immediate method for user-triggered actions (force=true) + // Use regular queued method for background periodic syncs + let success: boolean; + + if (force) { + // IMMEDIATE: User action (pause/unpause) - bypass queue + const contentData = buildContentData(); + success = await updateProgressImmediate(contentData, progressPercent); + + if (success) { + lastSyncTime.current = now; + lastSyncProgress.current = progressPercent; + + // Update local storage sync status + await storageService.updateTraktSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId, + currentTime + ); + + logger.log(`[TraktAutosync] IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`); + } + } else { + // BACKGROUND: Periodic sync - use queued method + const progressDiff = Math.abs(progressPercent - lastSyncProgress.current); + + // Only skip if not forced and progress difference is minimal (< 1%) + if (progressDiff < 1) { + return; + } + + const contentData = buildContentData(); + success = await updateProgress(contentData, progressPercent, force); + + if (success) { + lastSyncTime.current = now; + lastSyncProgress.current = progressPercent; + + // Update local storage sync status + await storageService.updateTraktSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId, + currentTime + ); + + // Progress sync logging removed + } } } catch (error) { logger.error('[TraktAutosync] Error syncing progress:', error); } - }, [isAuthenticated, autosyncSettings.enabled, updateProgress, buildContentData, options]); + }, [isAuthenticated, autosyncSettings.enabled, updateProgress, updateProgressImmediate, buildContentData, options]); // Handle playback end/pause - const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' = 'ended') => { + const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => { const now = Date.now(); - + // Removed excessive logging for handlePlaybackEnd calls - + if (!isAuthenticated || !autosyncSettings.enabled) { // logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); return; @@ -220,7 +249,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { if (hasStopped.current) { const currentProgressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; const progressImprovement = currentProgressPercent - lastSyncProgress.current; - + if (progressImprovement > 5) { logger.log(`[TraktAutosync] Session already stopped, but progress improved significantly by ${progressImprovement.toFixed(1)}% (${lastSyncProgress.current.toFixed(1)}% → ${currentProgressPercent.toFixed(1)}%), allowing update`); // Reset stopped flag to allow this significant update @@ -232,10 +261,14 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } } + // IMMEDIATE SYNC: Use immediate method for user-initiated actions (user_close) + let useImmediate = reason === 'user_close'; + // IMMEDIATE SYNC: Remove debouncing for instant sync when closing - // Only prevent truly duplicate calls (within 1 second) - if (!isSignificantUpdate && now - lastStopCall.current < 1000) { - logger.log(`[TraktAutosync] Ignoring duplicate stop call within 1 second (reason: ${reason})`); + // Only prevent truly duplicate calls (within 1 second for regular, 200ms for immediate) + const debounceThreshold = useImmediate ? 200 : 1000; + if (!isSignificantUpdate && now - lastStopCall.current < debounceThreshold) { + logger.log(`[TraktAutosync] Ignoring duplicate stop call within ${debounceThreshold}ms (reason: ${reason})`); return; } @@ -248,25 +281,25 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { try { let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; // Initial progress calculation logging removed - + // For unmount calls, always use the highest available progress // Check current progress, last synced progress, and local storage progress if (reason === 'unmount') { let maxProgress = progressPercent; - + // Check last synced progress if (lastSyncProgress.current > maxProgress) { maxProgress = lastSyncProgress.current; } - + // Also check local storage for the highest recorded progress try { const savedProgress = await storageService.getWatchProgress( - options.id, - options.type, + options.id, + options.type, options.episodeId ); - + if (savedProgress && savedProgress.duration > 0) { const savedProgressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; if (savedProgressPercent > maxProgress) { @@ -276,7 +309,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } catch (error) { logger.error('[TraktAutosync] Error checking saved progress:', error); } - + if (maxProgress !== progressPercent) { // Highest progress logging removed progressPercent = maxProgress; @@ -293,29 +326,31 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { hasStartedWatching.current = true; } } - + // Only stop if we have meaningful progress (>= 0.5%) or it's a natural video end // Lower threshold for unmount calls to catch more edge cases if (reason === 'unmount' && progressPercent < 0.5) { // Early unmount stop logging removed return; } - + // For natural end events, always set progress to at least 90% if (reason === 'ended' && progressPercent < 90) { logger.log(`[TraktAutosync] Natural end detected but progress is low (${progressPercent.toFixed(1)}%), boosting to 90%`); progressPercent = 90; } - + // Mark stop attempt and update timestamp lastStopCall.current = now; hasStopped.current = true; - + const contentData = buildContentData(); - - // Use stopWatching for proper scrobble stop - const success = await stopWatching(contentData, progressPercent); - + + // IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends + const success = useImmediate + ? await stopWatchingImmediate(contentData, progressPercent) + : await stopWatching(contentData, progressPercent); + if (success) { // Update local storage sync status await storageService.updateTraktSyncStatus( @@ -326,20 +361,20 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { options.episodeId, currentTime ); - + // Mark session as complete if high progress (scrobbled) if (progressPercent >= 80) { isSessionComplete.current = true; logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`); } - - logger.log(`[TraktAutosync] Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + + logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); } else { // If stop failed, reset the stop flag so we can try again later hasStopped.current = false; logger.warn(`[TraktAutosync] Failed to stop watching, reset stop flag for retry`); } - + // Reset state only for natural end or very high progress unmounts if (reason === 'ended' || progressPercent >= 80) { hasStartedWatching.current = false; @@ -347,13 +382,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { lastSyncProgress.current = 0; logger.log(`[TraktAutosync] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`); } - + } catch (error) { logger.error('[TraktAutosync] Error ending watch:', error); // Reset stop flag on error so we can try again hasStopped.current = false; } - }, [isAuthenticated, autosyncSettings.enabled, stopWatching, startWatching, buildContentData, options]); + }, [isAuthenticated, autosyncSettings.enabled, stopWatching, stopWatchingImmediate, startWatching, buildContentData, options]); // Reset state (useful when switching content) const resetState = useCallback(() => { diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index ccd62647..c06f1770 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -199,12 +199,12 @@ export function useTraktIntegration() { // Update progress while watching (scrobble pause) const updateProgress = useCallback(async ( - contentData: TraktContentData, - progress: number, + contentData: TraktContentData, + progress: number, force: boolean = false ): Promise => { if (!isAuthenticated) return false; - + try { return await traktService.scrobblePause(contentData, progress, force); } catch (error) { @@ -213,10 +213,27 @@ export function useTraktIntegration() { } }, [isAuthenticated]); + // IMMEDIATE SCROBBLE METHODS - Bypass queue for instant user feedback + + // Immediate update progress while watching (scrobble pause) + const updateProgressImmediate = useCallback(async ( + contentData: TraktContentData, + progress: number + ): Promise => { + if (!isAuthenticated) return false; + + try { + return await traktService.scrobblePauseImmediate(contentData, progress); + } catch (error) { + logger.error('[useTraktIntegration] Error updating progress immediately:', error); + return false; + } + }, [isAuthenticated]); + // Stop watching content (scrobble stop) const stopWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise => { if (!isAuthenticated) return false; - + try { return await traktService.scrobbleStop(contentData, progress); } catch (error) { @@ -225,6 +242,18 @@ export function useTraktIntegration() { } }, [isAuthenticated]); + // Immediate stop watching content (scrobble stop) + const stopWatchingImmediate = useCallback(async (contentData: TraktContentData, progress: number): Promise => { + if (!isAuthenticated) return false; + + try { + return await traktService.scrobbleStopImmediate(contentData, progress); + } catch (error) { + logger.error('[useTraktIntegration] Error stopping watch immediately:', error); + return false; + } + }, [isAuthenticated]); + // Sync progress to Trakt (legacy method) const syncProgress = useCallback(async ( contentData: TraktContentData, @@ -494,7 +523,9 @@ export function useTraktIntegration() { refreshAuthStatus, startWatching, updateProgress, + updateProgressImmediate, stopWatching, + stopWatchingImmediate, syncProgress, // legacy getTraktPlaybackProgress, syncAllProgress, diff --git a/src/services/traktService.ts b/src/services/traktService.ts index ce903f40..e5988a72 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -1492,7 +1492,7 @@ export class TraktService { const watchingKey = this.getWatchingKey(contentData); const now = Date.now(); - + // IMMEDIATE SYNC: Reduce debouncing for instant sync, only prevent truly duplicate calls (< 1 second) const lastStopTime = this.lastStopCalls.get(watchingKey); if (lastStopTime && (now - lastStopTime) < 1000) { @@ -1509,19 +1509,19 @@ export class TraktService { if (result) { this.currentlyWatching.delete(watchingKey); - + // Mark as scrobbled if >= 80% to prevent future duplicates and restarts if (progress >= this.completionThreshold) { this.scrobbledItems.add(watchingKey); this.scrobbledTimestamps.set(watchingKey, Date.now()); logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`); } - + // The stop endpoint automatically handles the 80%+ completion logic // and will mark as scrobbled if >= 80%, or pause if < 80% const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); - + return true; } else { // If failed, remove from lastStopCalls so we can try again @@ -1535,12 +1535,94 @@ export class TraktService { logger.warn('[TraktService] Rate limited, will retry later'); return true; } - + logger.error('[TraktService] Failed to stop scrobbling:', error); return false; } } + /** + * IMMEDIATE SCROBBLE METHODS - Bypass rate limiting queue for critical user actions + */ + + /** + * Immediate scrobble pause - bypasses queue for instant user feedback + */ + public async scrobblePauseImmediate(contentData: TraktContentData, progress: number): Promise { + try { + if (!await this.isAuthenticated()) { + return false; + } + + const watchingKey = this.getWatchingKey(contentData); + + // MINIMAL DEDUPLICATION: Only prevent calls within 100ms for immediate actions + const lastSync = this.lastSyncTimes.get(watchingKey) || 0; + if ((Date.now() - lastSync) < 100) { + return true; // Skip this sync, but return success + } + + this.lastSyncTimes.set(watchingKey, Date.now()); + + // BYPASS QUEUE: Call API directly for immediate response + const result = await this.pauseWatching(contentData, progress); + + if (result) { + logger.log(`[TraktService] IMMEDIATE: Updated progress ${progress.toFixed(1)}% for ${contentData.type}: ${contentData.title}`); + return true; + } + + return false; + } catch (error) { + logger.error('[TraktService] Failed to pause scrobbling immediately:', error); + return false; + } + } + + /** + * Immediate scrobble stop - bypasses queue for instant user feedback + */ + public async scrobbleStopImmediate(contentData: TraktContentData, progress: number): Promise { + try { + if (!await this.isAuthenticated()) { + return false; + } + + const watchingKey = this.getWatchingKey(contentData); + + // MINIMAL DEDUPLICATION: Only prevent calls within 200ms for immediate actions + const lastStopTime = this.lastStopCalls.get(watchingKey); + if (lastStopTime && (Date.now() - lastStopTime) < 200) { + return true; + } + + this.lastStopCalls.set(watchingKey, Date.now()); + + // BYPASS QUEUE: Call API directly for immediate response + const result = await this.stopWatching(contentData, progress); + + if (result) { + this.currentlyWatching.delete(watchingKey); + + // Mark as scrobbled if >= 80% to prevent future duplicates and restarts + if (progress >= this.completionThreshold) { + this.scrobbledItems.add(watchingKey); + this.scrobbledTimestamps.set(watchingKey, Date.now()); + } + + const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; + logger.log(`[TraktService] IMMEDIATE: Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); + + return true; + } + + return false; + } catch (error) { + logger.error('[TraktService] Failed to stop scrobbling immediately:', error); + return false; + } + } + /** * Legacy sync method - now delegates to proper scrobble methods * @deprecated Use scrobbleStart, scrobblePause, scrobbleStop instead