Enhance video player components with content duration tracking and progress updates

This update introduces functionality to store and update the actual video duration in both AndroidVideoPlayer and VideoPlayer components. It ensures that the progress is accurately maintained when the video duration changes significantly. Additionally, the useTraktIntegration and storageService have been updated to handle exact playback times from Trakt, improving synchronization and user experience. Logging has been refined for better clarity during progress updates.
This commit is contained in:
tapframe 2025-06-21 12:50:13 +05:30
parent 7627de32a9
commit 5e733f9eb2
6 changed files with 275 additions and 93 deletions

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

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

View file

@ -324,22 +324,21 @@ export function useTraktIntegration() {
// Fetch and merge Trakt progress with local progress
const fetchAndMergeTraktProgress = useCallback(async (): Promise<boolean> => {
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<void>[] = [];
// 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]);

View file

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

View file

@ -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<void> {
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<number | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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) {