diff --git a/app/api/watch/[videoId]/progress/route.ts b/app/api/watch/[videoId]/progress/route.ts index fe784ca..84f0068 100644 --- a/app/api/watch/[videoId]/progress/route.ts +++ b/app/api/watch/[videoId]/progress/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server'; import { db } from '@/lib/db'; import { auth, checkProjectAccess } from '@/lib/auth'; import { apiErrors, successResponse } from '@/lib/api-response'; +import { rateLimit } from '@/lib/rate-limit'; 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 export async function POST(request: NextRequest, { params }: RouteParams) { 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(); if (!session?.user?.id) { diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 739b793..c386896 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -424,6 +424,26 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi }, onStateChange: (event: YT.OnStateChangeEvent) => { 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) { const dur = event.target.getDuration(); if (dur > 0) setVideoDuration(dur); @@ -523,19 +543,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi // Save progress every 5 seconds while playing progressSaveTimerRef.current = setInterval(() => { - if (currentTime > 0 && Math.abs(currentTime - lastSavedProgressRef.current) >= 2) { - // Save to API + const playerCurrentTime = playerRef.current?.getCurrentTime?.() || 0; + 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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - progress: currentTime, - duration: videoDuration, + progress: playerCurrentTime, + duration: playerDuration || videoDuration, versionId: activeVersionId, }), }).catch((err) => console.error('Error saving watch progress:', err)); - lastSavedProgressRef.current = currentTime; + lastSavedProgressRef.current = playerCurrentTime; } }, 5000); @@ -551,19 +574,46 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi if (!video?.isAuthenticated) return; 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 const data = new Blob([JSON.stringify({ - progress: currentTime, - duration: videoDuration, + progress: playerCurrentTime, + duration: playerDuration, versionId: activeVersionId, })], { type: 'application/json' }); 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); - return () => window.removeEventListener('beforeunload', saveProgressOnLeave); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + window.removeEventListener('beforeunload', saveProgressOnLeave); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; }, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]); // Resume playback from saved position diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index cecadda..d86a30b 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -26,6 +26,9 @@ export const RATE_LIMIT_CONFIGS: Record = { 'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute '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 'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour 'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute