mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(watch): add pause and visibility change progress saving with rate limiting
- Save watch progress immediately when video is paused using player instance directly - Save progress when tab becomes hidden (user switches tabs or minimizes) - Add rate limiting (30/min) to watch progress API endpoint to prevent abuse - Use player instance directly instead of React state for current time/duration to avoid stale values
This commit is contained in:
@@ -2,6 +2,7 @@ import { NextRequest } from 'next/server';
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { auth, checkProjectAccess } from '@/lib/auth';
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
import { apiErrors, successResponse } from '@/lib/api-response';
|
import { apiErrors, successResponse } from '@/lib/api-response';
|
||||||
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ videoId: string }> };
|
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||||
|
|
||||||
@@ -69,6 +70,10 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
// POST /api/watch/[videoId]/progress - Save watch progress for the current user
|
// POST /api/watch/[videoId]/progress - Save watch progress for the current user
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
|
// Rate limit watch progress updates (30 per minute to allow pause + periodic + visibility changes)
|
||||||
|
const limited = await rateLimit(request, 'watch-progress');
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
|
|||||||
@@ -424,6 +424,26 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
},
|
},
|
||||||
onStateChange: (event: YT.OnStateChangeEvent) => {
|
onStateChange: (event: YT.OnStateChangeEvent) => {
|
||||||
setIsPlaying(event.data === YT.PlayerState.PLAYING);
|
setIsPlaying(event.data === YT.PlayerState.PLAYING);
|
||||||
|
|
||||||
|
// Save progress immediately when video is paused
|
||||||
|
if (event.data === YT.PlayerState.PAUSED) {
|
||||||
|
// Get current time and duration directly from player instance, not from React state (which may be stale)
|
||||||
|
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
||||||
|
const playerDuration = playerRef.current?.getDuration?.() || 0;
|
||||||
|
|
||||||
|
if (video?.isAuthenticated && playerCurrentTime > 0 && activeVersionId) {
|
||||||
|
fetch(`/api/watch/${videoId}/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
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) {
|
||||||
const dur = event.target.getDuration();
|
const dur = event.target.getDuration();
|
||||||
if (dur > 0) setVideoDuration(dur);
|
if (dur > 0) setVideoDuration(dur);
|
||||||
@@ -523,19 +543,22 @@ 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(() => {
|
||||||
if (currentTime > 0 && Math.abs(currentTime - lastSavedProgressRef.current) >= 2) {
|
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
||||||
// Save to API
|
const playerDuration = playerRef.current?.getDuration?.() || 0;
|
||||||
|
|
||||||
|
if (playerCurrentTime > 0 && Math.abs(playerCurrentTime - lastSavedProgressRef.current) >= 2) {
|
||||||
|
// Save to API - use player duration directly
|
||||||
fetch(`/api/watch/${videoId}/progress`, {
|
fetch(`/api/watch/${videoId}/progress`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
progress: currentTime,
|
progress: playerCurrentTime,
|
||||||
duration: videoDuration,
|
duration: playerDuration || videoDuration,
|
||||||
versionId: activeVersionId,
|
versionId: activeVersionId,
|
||||||
}),
|
}),
|
||||||
}).catch((err) => console.error('Error saving watch progress:', err));
|
}).catch((err) => console.error('Error saving watch progress:', err));
|
||||||
|
|
||||||
lastSavedProgressRef.current = currentTime;
|
lastSavedProgressRef.current = playerCurrentTime;
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
@@ -551,19 +574,46 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
if (!video?.isAuthenticated) return;
|
if (!video?.isAuthenticated) return;
|
||||||
|
|
||||||
const saveProgressOnLeave = () => {
|
const saveProgressOnLeave = () => {
|
||||||
if (currentTime > 0 && navigator.sendBeacon) {
|
// Get current time and duration directly from player instance
|
||||||
|
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || currentTime;
|
||||||
|
const playerDuration = playerRef.current?.getDuration?.() || videoDuration;
|
||||||
|
|
||||||
|
if (playerCurrentTime > 0 && navigator.sendBeacon) {
|
||||||
// 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: currentTime,
|
progress: playerCurrentTime,
|
||||||
duration: videoDuration,
|
duration: playerDuration,
|
||||||
versionId: activeVersionId,
|
versionId: activeVersionId,
|
||||||
})], { type: 'application/json' });
|
})], { type: 'application/json' });
|
||||||
navigator.sendBeacon(`/api/watch/${videoId}/progress`, data);
|
navigator.sendBeacon(`/api/watch/${videoId}/progress`, data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Save when tab becomes hidden (user switches tabs, minimizes, etc.)
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
// Get current time and duration directly from player instance
|
||||||
|
const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0;
|
||||||
|
const playerDuration = playerRef.current?.getDuration?.() || videoDuration;
|
||||||
|
|
||||||
|
if (document.visibilityState === 'hidden' && playerCurrentTime > 0 && activeVersionId) {
|
||||||
|
fetch(`/api/watch/${videoId}/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
progress: playerCurrentTime,
|
||||||
|
duration: playerDuration,
|
||||||
|
versionId: activeVersionId,
|
||||||
|
}),
|
||||||
|
}).catch((err) => console.error('Error saving watch progress on visibility change:', err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('beforeunload', saveProgressOnLeave);
|
window.addEventListener('beforeunload', saveProgressOnLeave);
|
||||||
return () => window.removeEventListener('beforeunload', saveProgressOnLeave);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', saveProgressOnLeave);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
}, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]);
|
}, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]);
|
||||||
|
|
||||||
// Resume playback from saved position
|
// Resume playback from saved position
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
|||||||
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
||||||
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
|
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
|
||||||
|
|
||||||
|
// Watch progress — allow frequent updates but prevent abuse
|
||||||
|
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)
|
||||||
|
|
||||||
// Member management
|
// Member management
|
||||||
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
|
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
|
||||||
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
||||||
|
|||||||
Reference in New Issue
Block a user