Improved trakt.

This commit is contained in:
tapframe 2025-07-29 14:28:52 +05:30
parent df89c16246
commit 6405fd2c71
6 changed files with 185 additions and 225 deletions

3
.gitignore vendored
View file

@ -42,4 +42,5 @@ CHANGELOG.md
android/
HEATING_OPTIMIZATIONS.md
ios
android
android
sliderreadme.md

View file

@ -264,16 +264,16 @@ const AndroidVideoPlayer: React.FC = () => {
}, []);
const startOpeningAnimation = () => {
// Logo entrance animation
// Logo entrance animation - optimized for faster appearance
Animated.parallel([
Animated.timing(logoOpacityAnim, {
toValue: 1,
duration: 600,
duration: 300, // Reduced from 600ms to 300ms
useNativeDriver: true,
}),
Animated.spring(logoScaleAnim, {
toValue: 1,
tension: 50,
tension: 80, // Increased tension for faster spring
friction: 8,
useNativeDriver: true,
}),
@ -284,12 +284,12 @@ const AndroidVideoPlayer: React.FC = () => {
return Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 1000,
duration: 800, // Reduced from 1000ms to 800ms
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
duration: 800, // Reduced from 1000ms to 800ms
useNativeDriver: true,
}),
]);
@ -303,38 +303,34 @@ const AndroidVideoPlayer: React.FC = () => {
});
};
// Start pulsing after a short delay
setTimeout(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
}, 800);
// Start pulsing immediately without delay
// Removed the 800ms delay
loopPulse();
};
const completeOpeningAnimation = () => {
Animated.parallel([
Animated.timing(openingFadeAnim, {
toValue: 1,
duration: 600,
duration: 300, // Reduced from 600ms to 300ms
useNativeDriver: true,
}),
Animated.timing(openingScaleAnim, {
toValue: 1,
duration: 700,
duration: 350, // Reduced from 700ms to 350ms
useNativeDriver: true,
}),
Animated.timing(backgroundFadeAnim, {
toValue: 0,
duration: 800,
duration: 400, // Reduced from 800ms to 400ms
useNativeDriver: true,
}),
]).start(() => {
openingScaleAnim.setValue(1);
openingFadeAnim.setValue(1);
setIsOpeningAnimationComplete(true);
setTimeout(() => {
backgroundFadeAnim.setValue(0);
}, 100);
// Removed the 100ms delay
backgroundFadeAnim.setValue(0);
});
};
@ -396,15 +392,14 @@ const AndroidVideoPlayer: React.FC = () => {
clearInterval(progressSaveInterval);
}
// Use the user's configured sync frequency with increased minimum to reduce heating
// Minimum interval increased from 5s to 30s to reduce CPU usage
const syncInterval = Math.max(30000, traktSettings.syncFrequency);
// IMMEDIATE SYNC: Reduce sync interval to 5 seconds for near real-time sync
const syncInterval = 5000; // 5 seconds for immediate sync
const interval = setInterval(() => {
saveWatchProgress();
}, syncInterval);
logger.log(`[AndroidVideoPlayer] Watch progress save interval set to ${syncInterval}ms`);
logger.log(`[AndroidVideoPlayer] Watch progress save interval set to ${syncInterval}ms (immediate sync mode)`);
setProgressSaveInterval(interval);
return () => {
@ -412,7 +407,7 @@ const AndroidVideoPlayer: React.FC = () => {
setProgressSaveInterval(null);
};
}
}, [id, type, paused, currentTime, duration, traktSettings.syncFrequency]);
}, [id, type, paused, currentTime, duration]);
useEffect(() => {
return () => {
@ -583,8 +578,12 @@ const AndroidVideoPlayer: React.FC = () => {
traktAutosync.handlePlaybackStart(currentTime, videoDuration);
}
// Complete opening animation immediately before seeking
completeOpeningAnimation();
if (initialPosition && !isInitialSeekComplete) {
logger.log(`[AndroidVideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`);
// Reduced timeout from 1000ms to 500ms
setTimeout(() => {
if (videoRef.current && videoDuration > 0 && isMounted.current) {
seekToTime(initialPosition);
@ -593,9 +592,9 @@ const AndroidVideoPlayer: React.FC = () => {
} else {
logger.error(`[AndroidVideoPlayer] Initial seek failed: videoRef=${!!videoRef.current}, duration=${videoDuration}, mounted=${isMounted.current}`);
}
}, 1000);
}, 500);
}
completeOpeningAnimation();
controlsTimeout.current = setTimeout(hideControls, 5000);
} catch (error) {
logger.error('[AndroidVideoPlayer] Error in onLoad:', error);
@ -651,9 +650,13 @@ const AndroidVideoPlayer: React.FC = () => {
};
const handleClose = async () => {
logger.log('[AndroidVideoPlayer] Close button pressed - syncing to Trakt before closing');
// Set syncing state to prevent multiple close attempts
// Prevent multiple close attempts
if (isSyncingBeforeClose) {
logger.log('[AndroidVideoPlayer] Close already in progress, ignoring duplicate call');
return;
}
logger.log('[AndroidVideoPlayer] Close button pressed - closing immediately and syncing to Trakt in background');
setIsSyncingBeforeClose(true);
// Make sure we have the most accurate current time
@ -662,44 +665,34 @@ const AndroidVideoPlayer: React.FC = () => {
logger.log(`[AndroidVideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`);
try {
// Force one last progress update (scrobble/pause) with the exact time
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
// Sync progress to Trakt before closing
await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount');
// Start exit animation
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(openingFadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start();
// Longer delay to ensure Trakt sync completes
setTimeout(() => {
ScreenOrientation.unlockAsync().then(() => {
disableImmersiveMode();
navigation.goBack();
}).catch(() => {
// Fallback: navigate even if orientation unlock fails
disableImmersiveMode();
navigation.goBack();
});
}, 500); // Increased from 100ms to 500ms
} catch (error) {
logger.error('[AndroidVideoPlayer] Error syncing to Trakt before closing:', error);
// Navigate anyway even if sync fails
// Navigate immediately without delay
ScreenOrientation.unlockAsync().then(() => {
disableImmersiveMode();
navigation.goBack();
}
}).catch(() => {
// Fallback: navigate even if orientation unlock fails
disableImmersiveMode();
navigation.goBack();
});
// Send Trakt sync in background (don't await)
const backgroundSync = async () => {
try {
logger.log('[AndroidVideoPlayer] Starting background Trakt sync');
// Force one last progress update (scrobble/pause) with the exact time
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
// Sync progress to Trakt
await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount');
logger.log('[AndroidVideoPlayer] Background Trakt sync completed successfully');
} catch (error) {
logger.error('[AndroidVideoPlayer] Error in background Trakt sync:', error);
}
};
// Start background sync without blocking UI
backgroundSync();
};
const handleResume = async () => {
@ -752,10 +745,8 @@ const AndroidVideoPlayer: React.FC = () => {
logger.log('[AndroidVideoPlayer] Video ended naturally, sending final progress update with 100%');
await traktAutosync.handleProgressUpdate(finalTime, duration, true);
// Small delay to ensure the progress update is processed
await new Promise(resolve => setTimeout(resolve, 300));
// Now send the stop call
// IMMEDIATE SYNC: Remove delay for instant sync
// Now send the stop call immediately
logger.log('[AndroidVideoPlayer] Sending final stop call after natural end');
await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended');
@ -1280,4 +1271,4 @@ const AndroidVideoPlayer: React.FC = () => {
);
};
export default AndroidVideoPlayer;
export default AndroidVideoPlayer;

View file

@ -286,16 +286,16 @@ const VideoPlayer: React.FC = () => {
}, []);
const startOpeningAnimation = () => {
// Logo entrance animation
// Logo entrance animation - optimized for faster appearance
Animated.parallel([
Animated.timing(logoOpacityAnim, {
toValue: 1,
duration: 600,
duration: 300, // Reduced from 600ms to 300ms
useNativeDriver: true,
}),
Animated.spring(logoScaleAnim, {
toValue: 1,
tension: 50,
tension: 80, // Increased tension for faster spring
friction: 8,
useNativeDriver: true,
}),
@ -306,12 +306,12 @@ const VideoPlayer: React.FC = () => {
return Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 1000,
duration: 800, // Reduced from 1000ms to 800ms
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
duration: 800, // Reduced from 1000ms to 800ms
useNativeDriver: true,
}),
]);
@ -325,29 +325,26 @@ const VideoPlayer: React.FC = () => {
});
};
// Start pulsing after a short delay
setTimeout(() => {
if (!isOpeningAnimationComplete) {
loopPulse();
}
}, 800);
// Start pulsing immediately without delay
// Removed the 800ms delay
loopPulse();
};
const completeOpeningAnimation = () => {
Animated.parallel([
Animated.timing(openingFadeAnim, {
toValue: 1,
duration: 600,
duration: 300, // Reduced from 600ms to 300ms
useNativeDriver: true,
}),
Animated.timing(openingScaleAnim, {
toValue: 1,
duration: 700,
duration: 350, // Reduced from 700ms to 350ms
useNativeDriver: true,
}),
Animated.timing(backgroundFadeAnim, {
toValue: 0,
duration: 800,
duration: 400, // Reduced from 800ms to 400ms
useNativeDriver: true,
}),
]).start(() => {
@ -418,15 +415,14 @@ const VideoPlayer: React.FC = () => {
clearInterval(progressSaveInterval);
}
// Use the user's configured sync frequency with increased minimum to reduce heating
// Minimum interval increased from 5s to 30s to reduce CPU usage
const syncInterval = Math.max(30000, traktSettings.syncFrequency);
// IMMEDIATE SYNC: Reduce sync interval to 5 seconds for near real-time sync
const syncInterval = 5000; // 5 seconds for immediate sync
const interval = setInterval(() => {
saveWatchProgress();
}, syncInterval);
logger.log(`[VideoPlayer] Watch progress save interval set to ${syncInterval}ms`);
logger.log(`[VideoPlayer] Watch progress save interval set to ${syncInterval}ms (immediate sync mode)`);
setProgressSaveInterval(interval);
return () => {
@ -434,7 +430,7 @@ const VideoPlayer: React.FC = () => {
setProgressSaveInterval(null);
};
}
}, [id, type, paused, currentTime, duration, traktSettings.syncFrequency]);
}, [id, type, paused, currentTime, duration]);
useEffect(() => {
return () => {
@ -629,8 +625,12 @@ const VideoPlayer: React.FC = () => {
traktAutosync.handlePlaybackStart(currentTime, videoDuration);
}
// Complete opening animation immediately before seeking
completeOpeningAnimation();
if (initialPosition && !isInitialSeekComplete) {
logger.log(`[VideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`);
// Reduced timeout from 1000ms to 500ms
setTimeout(() => {
if (vlcRef.current && videoDuration > 0 && isMounted.current) {
seekToTime(initialPosition);
@ -639,9 +639,9 @@ const VideoPlayer: React.FC = () => {
} else {
logger.error(`[VideoPlayer] Initial seek failed: vlcRef=${!!vlcRef.current}, duration=${videoDuration}, mounted=${isMounted.current}`);
}
}, 1000);
}, 500);
}
completeOpeningAnimation();
controlsTimeout.current = setTimeout(hideControls, 5000);
} catch (error) {
logger.error('[VideoPlayer] Error in onLoad:', error);
@ -704,9 +704,13 @@ const VideoPlayer: React.FC = () => {
};
const handleClose = async () => {
logger.log('[VideoPlayer] Close button pressed - syncing to Trakt before closing');
// Prevent multiple close attempts
if (isSyncingBeforeClose) {
logger.log('[VideoPlayer] Close already in progress, ignoring duplicate call');
return;
}
// Set syncing state to prevent multiple close attempts
logger.log('[VideoPlayer] Close button pressed - closing immediately and syncing to Trakt in background');
setIsSyncingBeforeClose(true);
// Make sure we have the most accurate current time
@ -715,75 +719,56 @@ const VideoPlayer: React.FC = () => {
logger.log(`[VideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`);
try {
// Force one last progress update (scrobble/pause) with the exact time
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
// Cleanup and navigate back immediately without delay
const cleanup = async () => {
try {
// Unlock orientation first
await ScreenOrientation.unlockAsync();
logger.log('[VideoPlayer] Orientation unlocked');
} catch (orientationError) {
logger.warn('[VideoPlayer] Failed to unlock orientation:', orientationError);
}
// Sync progress to Trakt before closing
await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount');
// Disable immersive mode
disableImmersiveMode();
// Start exit animation
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(openingFadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start();
// Cleanup and navigate back
const cleanup = async () => {
try {
// Unlock orientation first
await ScreenOrientation.unlockAsync();
logger.log('[VideoPlayer] Orientation unlocked');
} catch (orientationError) {
logger.warn('[VideoPlayer] Failed to unlock orientation:', orientationError);
}
// Disable immersive mode
disableImmersiveMode();
// Navigate back with proper handling for fullscreen modal
try {
if (navigation.canGoBack()) {
navigation.goBack();
} else {
// Fallback: navigate to main tabs if can't go back
navigation.navigate('MainTabs');
}
logger.log('[VideoPlayer] Navigation completed');
} catch (navError) {
logger.error('[VideoPlayer] Navigation error:', navError);
// Last resort: try to navigate to home
// Navigate back with proper handling for fullscreen modal
try {
if (navigation.canGoBack()) {
navigation.goBack();
} else {
// Fallback: navigate to main tabs if can't go back
navigation.navigate('MainTabs');
}
};
// Delay to ensure Trakt sync completes and animations finish
setTimeout(cleanup, 500);
} catch (error) {
logger.error('[VideoPlayer] Error syncing to Trakt before closing:', error);
// Navigate anyway even if sync fails
disableImmersiveMode();
try {
await ScreenOrientation.unlockAsync();
} catch (orientationError) {
// Ignore orientation unlock errors
}
if (navigation.canGoBack()) {
navigation.goBack();
} else {
logger.log('[VideoPlayer] Navigation completed');
} catch (navError) {
logger.error('[VideoPlayer] Navigation error:', navError);
// Last resort: try to navigate to home
navigation.navigate('MainTabs');
}
}
};
// Navigate immediately
cleanup();
// Send Trakt sync in background (don't await)
const backgroundSync = async () => {
try {
logger.log('[VideoPlayer] Starting background Trakt sync');
// Force one last progress update (scrobble/pause) with the exact time
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
// Sync progress to Trakt
await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount');
logger.log('[VideoPlayer] Background Trakt sync completed successfully');
} catch (error) {
logger.error('[VideoPlayer] Error in background Trakt sync:', error);
}
};
// Start background sync without blocking UI
backgroundSync();
};
const handleResume = async () => {
@ -836,10 +821,8 @@ const VideoPlayer: React.FC = () => {
logger.log('[VideoPlayer] Video ended naturally, sending final progress update with 100%');
await traktAutosync.handleProgressUpdate(finalTime, duration, true);
// Small delay to ensure the progress update is processed
await new Promise(resolve => setTimeout(resolve, 300));
// Now send the stop call
// IMMEDIATE SYNC: Remove delay for instant sync
// Now send the stop call immediately
logger.log('[VideoPlayer] Sending final stop call after natural end');
await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended');

View file

@ -165,11 +165,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
const progressPercent = (currentTime / duration) * 100;
const now = Date.now();
// Use the user's configured sync frequency
const timeSinceLastSync = now - lastSyncTime.current;
// IMMEDIATE SYNC: Remove all debouncing and frequency checks for instant sync
const progressDiff = Math.abs(progressPercent - lastSyncProgress.current);
if (!force && timeSinceLastSync < autosyncSettings.syncFrequency && progressDiff < 5) {
// Only skip if not forced and progress difference is minimal (< 1%)
if (!force && progressDiff < 1) {
return;
}
@ -195,7 +195,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
} catch (error) {
logger.error('[TraktAutosync] Error syncing progress:', error);
}
}, [isAuthenticated, autosyncSettings.enabled, autosyncSettings.syncFrequency, updateProgress, buildContentData, options]);
}, [isAuthenticated, autosyncSettings.enabled, updateProgress, buildContentData, options]);
// Handle playback end/pause
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' = 'ended') => {
@ -232,10 +232,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
}
}
// ENHANCED DEDUPLICATION: Prevent rapid successive calls (within 5 seconds)
// Bypass for significant updates
if (!isSignificantUpdate && now - lastStopCall.current < 5000) {
logger.log(`[TraktAutosync] Ignoring rapid successive stop call within 5 seconds (reason: ${reason})`);
// 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})`);
return;
}
@ -377,4 +377,4 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
handlePlaybackEnd,
resetState
};
}
}

View file

@ -645,60 +645,45 @@ export const StreamsScreen = () => {
}, [selectedEpisode, groupedEpisodes, id]);
const navigateToPlayer = useCallback(async (stream: Stream) => {
// Prepare available streams for the change source feature
const streamsToPass = type === 'series' ? episodeStreams : groupedStreams;
// Determine the stream name using the same logic as StreamCard
const streamName = stream.name || stream.title || 'Unnamed Stream';
// Navigate to player immediately without waiting for orientation lock
// This prevents delay in player opening
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
season: type === 'series' ? currentEpisode?.season_number : undefined,
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
year: metadata?.year,
streamProvider: stream.name,
streamName: streamName,
id,
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined,
imdbId: imdbId || undefined,
availableStreams: streamsToPass,
backdrop: bannerImage || undefined,
});
// Lock orientation to landscape after navigation has started
// This allows the player to open immediately while orientation is being set
try {
// Lock orientation to landscape before navigation to prevent glitches
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
// Small delay to ensure orientation is set before navigation
await new Promise(resolve => setTimeout(resolve, 100));
// Prepare available streams for the change source feature
const streamsToPass = type === 'series' ? episodeStreams : groupedStreams;
// Determine the stream name using the same logic as StreamCard
const streamName = stream.name || stream.title || 'Unnamed Stream';
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
season: type === 'series' ? currentEpisode?.season_number : undefined,
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
year: metadata?.year,
streamProvider: stream.name,
streamName: streamName,
id,
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined,
imdbId: imdbId || undefined,
availableStreams: streamsToPass,
backdrop: bannerImage || undefined,
});
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)
.catch(error => {
logger.error('[StreamsScreen] Error locking orientation after navigation:', error);
});
} catch (error) {
logger.error('[StreamsScreen] Error locking orientation before navigation:', error);
// Fallback: navigate anyway
const streamsToPass = type === 'series' ? episodeStreams : groupedStreams;
navigation.navigate('Player', {
uri: stream.url,
title: metadata?.name || '',
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
season: type === 'series' ? currentEpisode?.season_number : undefined,
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
year: metadata?.year,
streamProvider: stream.name,
id,
type,
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined,
imdbId: imdbId || undefined,
availableStreams: streamsToPass,
backdrop: bannerImage || undefined,
});
logger.error('[StreamsScreen] Error locking orientation after navigation:', error);
}
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage]);
// Update handleStreamPress
const handleStreamPress = useCallback(async (stream: Stream) => {
try {

View file

@ -269,14 +269,14 @@ export class TraktService {
private readonly SCROBBLE_EXPIRY_MS = 46 * 60 * 1000; // 46 minutes (based on Trakt's expiry window)
private scrobbledTimestamps: Map<string, number> = new Map();
// Track currently watching sessions to avoid duplicate starts
// Track currently watching sessions to avoid duplicate starts// Sync debouncing
private currentlyWatching: Set<string> = new Set();
private lastSyncTimes: Map<string, number> = new Map();
private readonly SYNC_DEBOUNCE_MS = 60000; // 60 seconds
private readonly SYNC_DEBOUNCE_MS = 1000; // 1 second for immediate sync
// Debounce for stop calls
private lastStopCalls: Map<string, number> = new Map();
private readonly STOP_DEBOUNCE_MS = 10000; // 10 seconds debounce for stop calls
private readonly STOP_DEBOUNCE_MS = 1000; // 1 second debounce for immediate stop calls
// Default completion threshold (overridden by user settings)
private readonly DEFAULT_COMPLETION_THRESHOLD = 80; // 80%
@ -1336,8 +1336,8 @@ export class TraktService {
const watchingKey = this.getWatchingKey(contentData);
const lastSync = this.lastSyncTimes.get(watchingKey) || 0;
// Debounce API calls unless forced
if (!force && (now - lastSync) < this.SYNC_DEBOUNCE_MS) {
// IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 500ms)
if (!force && (now - lastSync) < 500) {
return true; // Skip this sync, but return success
}
@ -1377,9 +1377,9 @@ export class TraktService {
const watchingKey = this.getWatchingKey(contentData);
const now = Date.now();
// Enhanced deduplication: Check if we recently stopped this content
// IMMEDIATE SYNC: Reduce debouncing for instant sync, only prevent truly duplicate calls (< 1 second)
const lastStopTime = this.lastStopCalls.get(watchingKey);
if (lastStopTime && (now - lastStopTime) < this.STOP_DEBOUNCE_MS) {
if (lastStopTime && (now - lastStopTime) < 1000) {
logger.log(`[TraktService] Ignoring duplicate stop call for ${contentData.title} (last stop ${((now - lastStopTime) / 1000).toFixed(1)}s ago)`);
return true; // Return success to avoid error handling
}
@ -1588,4 +1588,4 @@ export class TraktService {
}
// Export a singleton instance
export const traktService = TraktService.getInstance();
export const traktService = TraktService.getInstance();