optimize watch progress persistence with debounced client saves and tiny-delta server skip

This commit is contained in:
Yusuf İpek
2026-02-24 17:51:40 +03:00
parent 71233f8fe8
commit 9f32aeafe6
2 changed files with 182 additions and 69 deletions
+40 -14
View File
@@ -122,29 +122,55 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
// Calculate percentage // Calculate percentage
const safeDuration = duration || 0; const safeDuration = duration || 0;
const percentage = safeDuration > 0 ? Math.min(100, (progress / safeDuration) * 100) : 0; const percentage = safeDuration > 0 ? Math.min(100, (progress / safeDuration) * 100) : 0;
const tinyProgressDelta = 0.5;
const tinyDurationDelta = 1;
// Upsert watch progress const existingWatchProgress = await db.watchProgress.findUnique({
const watchProgress = await db.watchProgress.upsert({
where: { where: {
userId_versionId: { userId_versionId: {
userId: session.user.id, userId: session.user.id,
versionId: targetVersion.id, versionId: targetVersion.id,
}, },
}, },
update: {
progress,
duration: safeDuration,
percentage,
},
create: {
userId: session.user.id,
versionId: targetVersion.id,
progress,
duration: safeDuration,
percentage,
},
}); });
if (existingWatchProgress) {
const progressDiff = Math.abs(existingWatchProgress.progress - progress);
const durationDiff = Math.abs(existingWatchProgress.duration - safeDuration);
if (progressDiff < tinyProgressDelta && durationDiff < tinyDurationDelta) {
return successResponse({
success: true,
progress: existingWatchProgress.progress,
percentage: existingWatchProgress.percentage,
skipped: true,
});
}
}
const watchProgress = existingWatchProgress
? await db.watchProgress.update({
where: {
userId_versionId: {
userId: session.user.id,
versionId: targetVersion.id,
},
},
data: {
progress,
duration: safeDuration,
percentage,
},
})
: await db.watchProgress.create({
data: {
userId: session.user.id,
versionId: targetVersion.id,
progress,
duration: safeDuration,
percentage,
},
});
return successResponse({ return successResponse({
success: true, success: true,
progress: watchProgress.progress, progress: watchProgress.progress,
+142 -55
View File
@@ -314,6 +314,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [savedProgress, setSavedProgress] = useState<number | null>(null); const [savedProgress, setSavedProgress] = useState<number | null>(null);
const [showResumePrompt, setShowResumePrompt] = useState(false); const [showResumePrompt, setShowResumePrompt] = useState(false);
const progressSaveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); const progressSaveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const progressDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressWriteInFlightRef = useRef(false);
const pendingProgressPayloadRef = useRef<{ progress: number; duration: number; force: boolean } | null>(null);
const lastSavedProgressRef = useRef<number>(0); const lastSavedProgressRef = useRef<number>(0);
const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const bunnyRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -794,6 +797,108 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}; };
}, [isApiLoaded]); }, [isApiLoaded]);
const flushScheduledWatchProgress = useCallback(async () => {
if (!video?.isAuthenticated || !activeVersionId || progressWriteInFlightRef.current) return;
const nextPayload = pendingProgressPayloadRef.current;
if (!nextPayload) return;
if (!nextPayload.force && Math.abs(nextPayload.progress - lastSavedProgressRef.current) < 2) {
pendingProgressPayloadRef.current = null;
return;
}
pendingProgressPayloadRef.current = null;
progressWriteInFlightRef.current = true;
try {
const response = await fetch(`/api/watch/${videoId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
progress: nextPayload.progress,
duration: nextPayload.duration,
versionId: activeVersionId,
}),
});
if (response.ok) {
lastSavedProgressRef.current = nextPayload.progress;
}
} catch (err) {
console.error('Error saving watch progress:', err);
} finally {
progressWriteInFlightRef.current = false;
if (pendingProgressPayloadRef.current) {
void flushScheduledWatchProgress();
}
}
}, [video?.isAuthenticated, activeVersionId, videoId]);
const scheduleWatchProgressSave = useCallback((input: {
progress: number;
duration?: number;
immediate?: boolean;
force?: boolean;
}) => {
if (!video?.isAuthenticated || !activeVersionId) return;
const progress = Math.max(0, input.progress);
if (progress <= 0) return;
const duration = Math.max(0, input.duration ?? videoDuration ?? 0);
const force = input.force ?? false;
if (!force && Math.abs(progress - lastSavedProgressRef.current) < 2) {
return;
}
const existingPayload = pendingProgressPayloadRef.current;
pendingProgressPayloadRef.current = existingPayload
? {
progress: Math.max(existingPayload.progress, progress),
duration: Math.max(existingPayload.duration, duration),
force: existingPayload.force || force,
}
: { progress, duration, force };
if (input.immediate) {
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
void flushScheduledWatchProgress();
return;
}
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
}
progressDebounceTimerRef.current = setTimeout(() => {
progressDebounceTimerRef.current = null;
void flushScheduledWatchProgress();
}, 800);
}, [video?.isAuthenticated, activeVersionId, videoDuration, flushScheduledWatchProgress]);
useEffect(() => {
return () => {
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
};
}, []);
useEffect(() => {
lastSavedProgressRef.current = 0;
pendingProgressPayloadRef.current = null;
progressWriteInFlightRef.current = false;
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
}, [videoId, activeVersionId]);
useEffect(() => { useEffect(() => {
if (!canInitializePlayer) return; if (!canInitializePlayer) return;
if (!activeProviderId) return; if (!activeProviderId) return;
@@ -843,18 +948,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (event.data === YT.PlayerState.PAUSED) { if (event.data === YT.PlayerState.PAUSED) {
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0; const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
const playerDuration = playerRef.current?.getDuration?.() || 0; const playerDuration = playerRef.current?.getDuration?.() || 0;
scheduleWatchProgressSave({
if (video?.isAuthenticated && playerCurrentTime > 0 && activeVersionId) { progress: playerCurrentTime,
fetch(`/api/watch/${videoId}/progress`, { duration: playerDuration,
method: 'POST', immediate: true,
headers: { 'Content-Type': 'application/json' }, force: true,
body: JSON.stringify({ });
progress: playerCurrentTime,
duration: playerDuration,
versionId: activeVersionId,
}),
}).catch((err) => console.error('Error saving watch progress on pause:', err));
}
} }
if (event.data === YT.PlayerState.PLAYING) { if (event.data === YT.PlayerState.PLAYING) {
@@ -918,17 +1017,12 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const saveProgress = () => { const saveProgress = () => {
const current = videoEl.currentTime || 0; const current = videoEl.currentTime || 0;
const duration = Number.isFinite(videoEl.duration) && videoEl.duration > 0 ? videoEl.duration : cachedDuration; const duration = Number.isFinite(videoEl.duration) && videoEl.duration > 0 ? videoEl.duration : cachedDuration;
if (video?.isAuthenticated && current > 0 && activeVersionId) { scheduleWatchProgressSave({
fetch(`/api/watch/${videoId}/progress`, { progress: current,
method: 'POST', duration,
headers: { 'Content-Type': 'application/json' }, immediate: true,
body: JSON.stringify({ force: true,
progress: current, });
duration,
versionId: activeVersionId,
}),
}).catch((err) => console.error('Error saving watch progress on pause:', err));
}
}; };
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
@@ -1134,7 +1228,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
bunnyRetryTimerRef.current = null; bunnyRetryTimerRef.current = null;
} }
}; };
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId, canInitializePlayer]); }, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId, canInitializePlayer, scheduleWatchProgressSave]);
// Save detected duration to DB if the version doesn't have one stored // Save detected duration to DB if the version doesn't have one stored
useEffect(() => { useEffect(() => {
@@ -1212,32 +1306,21 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
// Save progress every 5 seconds while playing // Save progress every 5 seconds while playing
progressSaveTimerRef.current = setInterval(() => { progressSaveTimerRef.current = setInterval(() => {
const save = (playerCurrentTime: number, playerDuration: number) => {
if (playerCurrentTime > 0 && Math.abs(playerCurrentTime - lastSavedProgressRef.current) >= 2) {
fetch(`/api/watch/${videoId}/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
progress: playerCurrentTime,
duration: playerDuration || videoDuration,
versionId: activeVersionId,
}),
}).catch((err) => console.error('Error saving watch progress:', err));
lastSavedProgressRef.current = playerCurrentTime;
}
};
if (playerRef.current?.getCurrentTime) { if (playerRef.current?.getCurrentTime) {
save(playerRef.current.getCurrentTime(), playerRef.current.getDuration?.() || videoDuration); scheduleWatchProgressSave({
progress: playerRef.current.getCurrentTime(),
duration: playerRef.current.getDuration?.() || videoDuration,
});
} }
}, 5000); }, 5000);
return () => { return () => {
if (progressSaveTimerRef.current) { if (progressSaveTimerRef.current) {
clearInterval(progressSaveTimerRef.current); clearInterval(progressSaveTimerRef.current);
progressSaveTimerRef.current = null;
} }
}; };
}, [video?.isAuthenticated, isReady, currentTime, videoDuration, activeVersionId, videoId]); }, [video?.isAuthenticated, isReady, videoDuration, activeVersionId, scheduleWatchProgressSave]);
const toggleFullscreen = useCallback(() => { const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
@@ -1281,12 +1364,19 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
// Get current time and duration directly from player instance // Get current time and duration directly from player instance
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || currentTime; const playerCurrentTime = playerRef.current?.getCurrentTime?.() || currentTime;
const playerDuration = playerRef.current?.getDuration?.() || videoDuration; const playerDuration = playerRef.current?.getDuration?.() || videoDuration;
const pendingPayload = pendingProgressPayloadRef.current;
const finalProgress = Math.max(playerCurrentTime, pendingPayload?.progress ?? 0);
const finalDuration = Math.max(playerDuration, pendingPayload?.duration ?? 0);
if (playerCurrentTime > 0 && navigator.sendBeacon) { if (finalProgress > 0 && navigator.sendBeacon && activeVersionId) {
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
// Use sendBeacon for reliable save on page unload // Use sendBeacon for reliable save on page unload
const data = new Blob([JSON.stringify({ const data = new Blob([JSON.stringify({
progress: playerCurrentTime, progress: finalProgress,
duration: playerDuration, duration: finalDuration,
versionId: activeVersionId, versionId: activeVersionId,
})], { type: 'application/json' }); })], { type: 'application/json' });
navigator.sendBeacon(`/api/watch/${videoId}/progress`, data); navigator.sendBeacon(`/api/watch/${videoId}/progress`, data);
@@ -1299,16 +1389,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0; const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
const playerDuration = playerRef.current?.getDuration?.() || videoDuration; const playerDuration = playerRef.current?.getDuration?.() || videoDuration;
if (document.visibilityState === 'hidden' && playerCurrentTime > 0 && activeVersionId) { if (document.visibilityState === 'hidden') {
fetch(`/api/watch/${videoId}/progress`, { scheduleWatchProgressSave({
method: 'POST', progress: playerCurrentTime,
headers: { 'Content-Type': 'application/json' }, duration: playerDuration,
body: JSON.stringify({ immediate: true,
progress: playerCurrentTime, force: true,
duration: playerDuration, });
versionId: activeVersionId,
}),
}).catch((err) => console.error('Error saving watch progress on visibility change:', err));
} }
}; };
@@ -1318,7 +1405,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
window.removeEventListener('beforeunload', saveProgressOnLeave); window.removeEventListener('beforeunload', saveProgressOnLeave);
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]); }, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId, scheduleWatchProgressSave]);
const handleResumeFromSaved = useCallback(() => { const handleResumeFromSaved = useCallback(() => {
if (savedProgress !== null && playerRef.current) { if (savedProgress !== null && playerRef.current) {