This update integrates Trakt autosync capabilities into the AndroidVideoPlayer and VideoPlayer components, allowing for automatic syncing of watch progress and playback events. Key features include starting a watching session, updating progress during playback, and handling playback end events to ensure accurate tracking. Additionally, the useTraktIntegration and useTraktAutosync hooks have been enhanced to support these functionalities, improving the overall user experience by maintaining consistent watch history across devices.
258 lines
No EOL
9.8 KiB
TypeScript
258 lines
No EOL
9.8 KiB
TypeScript
import { useCallback, useRef, useEffect } from 'react';
|
|
import { useTraktIntegration } from './useTraktIntegration';
|
|
import { useTraktAutosyncSettings } from './useTraktAutosyncSettings';
|
|
import { TraktContentData } from '../services/traktService';
|
|
import { storageService } from '../services/storageService';
|
|
import { logger } from '../utils/logger';
|
|
|
|
interface TraktAutosyncOptions {
|
|
id: string;
|
|
type: 'movie' | 'series';
|
|
title: string;
|
|
year: number | string; // Allow both for compatibility
|
|
imdbId: string;
|
|
// For episodes
|
|
season?: number;
|
|
episode?: number;
|
|
showTitle?: string;
|
|
showYear?: number | string; // Allow both for compatibility
|
|
showImdbId?: string;
|
|
episodeId?: string;
|
|
}
|
|
|
|
export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|
const {
|
|
isAuthenticated,
|
|
startWatching,
|
|
updateProgress,
|
|
stopWatching
|
|
} = useTraktIntegration();
|
|
|
|
const { settings: autosyncSettings } = useTraktAutosyncSettings();
|
|
|
|
const hasStartedWatching = useRef(false);
|
|
const lastSyncTime = useRef(0);
|
|
const lastSyncProgress = useRef(0);
|
|
const sessionKey = useRef<string | null>(null);
|
|
const unmountCount = useRef(0);
|
|
|
|
// Generate a unique session key for this content instance
|
|
useEffect(() => {
|
|
const contentKey = options.type === 'movie'
|
|
? `movie:${options.imdbId}`
|
|
: `episode:${options.imdbId}:${options.season}:${options.episode}`;
|
|
sessionKey.current = `${contentKey}:${Date.now()}`;
|
|
|
|
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
|
|
|
|
return () => {
|
|
unmountCount.current++;
|
|
logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
|
|
};
|
|
}, [options.imdbId, options.season, options.episode, options.type]);
|
|
|
|
// Build Trakt content data from options
|
|
const buildContentData = useCallback((): TraktContentData => {
|
|
// Ensure year is a number and valid
|
|
const parseYear = (year: number | string | undefined): number => {
|
|
if (!year) return 0;
|
|
if (typeof year === 'number') return year;
|
|
const parsed = parseInt(year.toString(), 10);
|
|
return isNaN(parsed) ? 0 : parsed;
|
|
};
|
|
|
|
const numericYear = parseYear(options.year);
|
|
const numericShowYear = parseYear(options.showYear);
|
|
|
|
// Validate required fields
|
|
if (!options.title || !options.imdbId) {
|
|
logger.warn('[TraktAutosync] Missing required fields:', { title: options.title, imdbId: options.imdbId });
|
|
}
|
|
|
|
if (options.type === 'movie') {
|
|
return {
|
|
type: 'movie',
|
|
imdbId: options.imdbId,
|
|
title: options.title,
|
|
year: numericYear
|
|
};
|
|
} else {
|
|
return {
|
|
type: 'episode',
|
|
imdbId: options.imdbId,
|
|
title: options.title,
|
|
year: numericYear,
|
|
season: options.season,
|
|
episode: options.episode,
|
|
showTitle: options.showTitle || options.title,
|
|
showYear: numericShowYear || numericYear,
|
|
showImdbId: options.showImdbId || options.imdbId
|
|
};
|
|
}
|
|
}, [options]);
|
|
|
|
// Start watching (scrobble start)
|
|
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
|
|
logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, session=${sessionKey.current}`);
|
|
|
|
if (!isAuthenticated || !autosyncSettings.enabled || hasStartedWatching.current) {
|
|
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}`);
|
|
return;
|
|
}
|
|
|
|
if (duration <= 0) {
|
|
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: invalid duration (${duration})`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const progressPercent = (currentTime / duration) * 100;
|
|
const contentData = buildContentData();
|
|
|
|
const success = await startWatching(contentData, progressPercent);
|
|
if (success) {
|
|
hasStartedWatching.current = true;
|
|
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[TraktAutosync] Error starting watch:', error);
|
|
}
|
|
}, [isAuthenticated, autosyncSettings.enabled, startWatching, buildContentData]);
|
|
|
|
// Sync progress during playback
|
|
const handleProgressUpdate = useCallback(async (
|
|
currentTime: number,
|
|
duration: number,
|
|
force: boolean = false
|
|
) => {
|
|
if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const progressPercent = (currentTime / duration) * 100;
|
|
const now = Date.now();
|
|
|
|
// Use the user's configured sync frequency
|
|
const timeSinceLastSync = now - lastSyncTime.current;
|
|
const progressDiff = Math.abs(progressPercent - lastSyncProgress.current);
|
|
|
|
if (!force && timeSinceLastSync < autosyncSettings.syncFrequency && progressDiff < 5) {
|
|
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
|
|
);
|
|
|
|
logger.log(`[TraktAutosync] Synced progress ${progressPercent.toFixed(1)}%: ${contentData.title}`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[TraktAutosync] Error syncing progress:', error);
|
|
}
|
|
}, [isAuthenticated, autosyncSettings.enabled, autosyncSettings.syncFrequency, updateProgress, buildContentData, options]);
|
|
|
|
// Handle playback end/pause
|
|
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' = 'ended') => {
|
|
logger.log(`[TraktAutosync] handlePlaybackEnd called: reason=${reason}, time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, started=${hasStartedWatching.current}, session=${sessionKey.current}, unmountCount=${unmountCount.current}`);
|
|
|
|
if (!isAuthenticated || !autosyncSettings.enabled) {
|
|
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
|
|
return;
|
|
}
|
|
|
|
// Skip rapid unmount calls (likely from React strict mode or component remounts)
|
|
if (reason === 'unmount' && unmountCount.current > 1) {
|
|
logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
logger.log(`[TraktAutosync] Initial progress calculation: ${progressPercent.toFixed(1)}%`);
|
|
|
|
// If progress is 0 during unmount, use the last synced progress instead
|
|
// This happens when video player state is reset before component unmount
|
|
if (reason === 'unmount' && progressPercent < 1 && lastSyncProgress.current > 0) {
|
|
progressPercent = lastSyncProgress.current;
|
|
logger.log(`[TraktAutosync] Using last synced progress for unmount: ${progressPercent.toFixed(1)}%`);
|
|
}
|
|
|
|
// If we have valid progress but no started session, force start one first
|
|
if (!hasStartedWatching.current && progressPercent > 1) {
|
|
logger.log(`[TraktAutosync] Force starting session for progress: ${progressPercent.toFixed(1)}%`);
|
|
const contentData = buildContentData();
|
|
const success = await startWatching(contentData, progressPercent);
|
|
if (success) {
|
|
hasStartedWatching.current = true;
|
|
logger.log(`[TraktAutosync] Force started watching: ${contentData.title}`);
|
|
}
|
|
}
|
|
|
|
// Only stop if we have meaningful progress (>= 1%) or it's a natural video end
|
|
// Skip unmount calls with very low progress unless video actually ended
|
|
if (reason === 'unmount' && progressPercent < 1) {
|
|
logger.log(`[TraktAutosync] Skipping unmount stop for ${options.title} - too early (${progressPercent.toFixed(1)}%)`);
|
|
return;
|
|
}
|
|
|
|
const contentData = buildContentData();
|
|
|
|
// Use stopWatching for proper scrobble stop
|
|
const success = await stopWatching(contentData, progressPercent);
|
|
|
|
if (success) {
|
|
// Update local storage sync status
|
|
await storageService.updateTraktSyncStatus(
|
|
options.id,
|
|
options.type,
|
|
true,
|
|
progressPercent,
|
|
options.episodeId
|
|
);
|
|
}
|
|
|
|
// Reset state only for natural end or very high progress unmounts
|
|
if (reason === 'ended' || progressPercent >= 80) {
|
|
hasStartedWatching.current = false;
|
|
lastSyncTime.current = 0;
|
|
lastSyncProgress.current = 0;
|
|
logger.log(`[TraktAutosync] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`);
|
|
}
|
|
|
|
logger.log(`[TraktAutosync] Ended watching: ${options.title} (${reason})`);
|
|
} catch (error) {
|
|
logger.error('[TraktAutosync] Error ending watch:', error);
|
|
}
|
|
}, [isAuthenticated, autosyncSettings.enabled, stopWatching, buildContentData, options]);
|
|
|
|
// Reset state (useful when switching content)
|
|
const resetState = useCallback(() => {
|
|
hasStartedWatching.current = false;
|
|
lastSyncTime.current = 0;
|
|
lastSyncProgress.current = 0;
|
|
unmountCount.current = 0;
|
|
sessionKey.current = null;
|
|
logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`);
|
|
}, [options.title]);
|
|
|
|
return {
|
|
isAuthenticated,
|
|
handlePlaybackStart,
|
|
handleProgressUpdate,
|
|
handlePlaybackEnd,
|
|
resetState
|
|
};
|
|
}
|