mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
optimize watch progress persistence with debounced client saves and tiny-delta server skip
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user