'use client'; import React from 'react'; import { useSearchParams, useRouter } from 'next/navigation'; import { usePlaylist } from '@/hooks/usePlaylist'; import { useVideo } from '@/hooks/useVideo'; import { Card, CardContent, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { SegmentedProgressBar, WatchSegment } from '@/components/segmented-progress-bar'; import { AppSidebar } from '@/components/app-sidebar'; import { SiteHeader } from '@/components/site-header'; import { CommentsSection } from '@/components/comments-section'; import { formatDistanceToNow } from 'date-fns'; import { Lock, Heart, Edit } from 'lucide-react'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { Button } from '@/components/ui/button'; import { HlsPlayer } from '@/components/hls'; import { useSession } from 'next-auth/react'; import Image from 'next/image'; type VideoFromApi = { id: string; title: string; description?: string | null; durationSec?: number | null; duration?: string | null; thumbnail?: string | null; url?: string | null; locked?: boolean; createdAt?: string | null; transcodingStatus?: string; videoUrls?: { hlsUrl: string | null; mp4Url: string; }; uploader?: { id: string; name?: string; image?: string; } | null; }; export function useWatchTracker( videoRef: React.RefObject, opts: { sendIntervalMs?: number; commitIntervalSec?: number; debug?: boolean } = {} ) { const sendIntervalMs = opts.sendIntervalMs ?? 5000; const commitIntervalSec = opts.commitIntervalSec ?? 3; const debug = !!opts.debug; const rangesRef = React.useRef>([]); const lastTimeRef = React.useRef(null); const currentRangeStartRef = React.useRef(null); const sendTimerRef = React.useRef(null); const commitTimerRef = React.useRef(null); // merging helper function mergeRanges(ranges: Array<[number, number]>) { if (!ranges.length) return []; ranges.sort((a, b) => a[0] - b[0]); const merged: Array<[number, number]> = []; for (const [s, e] of ranges) { if (!merged.length) merged.push([s, e]); else { const last = merged[merged.length - 1]; if (s <= last[1] + 0.5) last[1] = Math.max(last[1], e); else merged.push([s, e]); } } return merged; } function addRange(s: number, e: number) { if (e <= s) return; if (debug) console.debug("[tracker] addRange", s, e); rangesRef.current.push([s, e]); rangesRef.current = mergeRanges(rangesRef.current); } function getUniqueWatchedSec() { let sum = 0; for (const [a, b] of rangesRef.current) sum += Math.max(0, b - a); return Math.round(sum); } // commit the "current" playing segment to ranges (useful while playing) function commitCurrentRange() { const el = videoRef.current; if (!el) return; const start = currentRangeStartRef.current; const now = el.currentTime; if (start === null) return; // only commit if we've moved forward at least 0.5s to avoid noise if (now - start >= 0.5) { addRange(start, now); // keep currentRangeStart open at 'now' so we continue accumulating currentRangeStartRef.current = now; } } // send progress to server (same signature you had) async function sendProgress(args: { videoId: string; playlistId?: string; duration: number; extra?: Record }) { const { videoId, playlistId, duration, extra } = args; const watchedSec = getUniqueWatchedSec(); const el = videoRef.current; const lastPos = Math.floor(el?.currentTime ?? 0); if (!duration || duration <= 0) return null; if (watchedSec === 0 && (el?.paused ?? true)) return null; const payload = { videoId, playlistId, watchedSec, lastPos, duration, ...extra }; try { if (debug) console.debug("[tracker] sendProgress", payload); const res = await fetch("/api/progress", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); // If video not found, stop trying to track progress if (!res.ok) { if (res.status === 404) { console.warn(`[tracker] Video ${videoId} not found, stopping progress tracking`); stopAutoSend(); // Stop automatic progress tracking for deleted video return null; } return null; } return res.ok ? await res.json().catch(() => null) : null; } catch (err) { if (debug) console.warn("[tracker] sendProgress error", err); return null; } } // start/stop auto-send function startAutoSend(args: { videoId: string; playlistId?: string; duration: number }) { stopAutoSend(); // clear existing // immediate send (best-effort) sendProgress(args).catch(() => {}); // periodic send sendTimerRef.current = window.setInterval(() => { const watched = getUniqueWatchedSec(); const el = videoRef.current; const isPlaying = !!(el && !el.paused && !el.ended); if (watched > 0 || isPlaying) { sendProgress(args).catch(() => {}); } }, sendIntervalMs) as unknown as number; // commit currently playing ranges periodically (so continuous play is captured) commitTimerRef.current = window.setInterval(() => { commitCurrentRange(); }, commitIntervalSec * 1000) as unknown as number; if (debug) console.debug("[tracker] startAutoSend", { sendIntervalMs, commitIntervalSec }); } function stopAutoSend(opts?: { sendFinal?: boolean; videoId?: string; playlistId?: string; duration?: number }) { if (sendTimerRef.current) { window.clearInterval(sendTimerRef.current); sendTimerRef.current = null; } if (commitTimerRef.current) { window.clearInterval(commitTimerRef.current); commitTimerRef.current = null; } if (opts?.sendFinal && opts.videoId && opts.duration) { sendProgress({ videoId: opts.videoId, playlistId: opts.playlistId, duration: opts.duration }).catch(() => {}); } if (debug) console.debug("[tracker] stopAutoSend"); } // attach listeners to populate rangesRef React.useEffect(() => { const el = videoRef.current; if (!el) { if (debug) console.debug("[tracker] no video element to attach"); return; } currentRangeStartRef.current = null; lastTimeRef.current = el.currentTime ?? 0; const onPlay = () => { currentRangeStartRef.current = el.currentTime; lastTimeRef.current = el.currentTime; if (debug) console.debug("[tracker] play", currentRangeStartRef.current); }; const onPause = () => { if (currentRangeStartRef.current !== null) { addRange(currentRangeStartRef.current, el.currentTime); currentRangeStartRef.current = null; } if (debug) console.debug("[tracker] pause, ranges:", rangesRef.current); }; const onTimeUpdate = () => { const now = el.currentTime; const last = lastTimeRef.current ?? now; // detect seek (big jump) if (Math.abs(now - last) > 2.5) { if (currentRangeStartRef.current !== null) { addRange(currentRangeStartRef.current, last); } currentRangeStartRef.current = now; if (debug) console.debug("[tracker] seek detected, start:", now); } lastTimeRef.current = now; // we do not addRange here to avoid flooding; commit timer handles mid-play commits }; const onEnded = () => { if (currentRangeStartRef.current !== null) { addRange(currentRangeStartRef.current, el.duration ?? lastTimeRef.current ?? 0); currentRangeStartRef.current = null; } if (debug) console.debug("[tracker] ended, ranges:", rangesRef.current); }; el.addEventListener("play", onPlay); el.addEventListener("pause", onPause); el.addEventListener("timeupdate", onTimeUpdate); el.addEventListener("ended", onEnded); return () => { el.removeEventListener("play", onPlay); el.removeEventListener("pause", onPause); el.removeEventListener("timeupdate", onTimeUpdate); el.removeEventListener("ended", onEnded); stopAutoSend(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [videoRef.current]); return { getUniqueWatchedSec, addRange, sendProgress, startAutoSend, stopAutoSend, }; } export default function Page() { const search = useSearchParams(); const router = useRouter(); const playlistId = search.get('playlistId') ?? undefined; const videoId = search.get('videoId') ?? undefined; const { playlist, isLoading: playlistLoading } = usePlaylist(playlistId); const { video, next, isLoading: videoLoading } = useVideo(videoId); // Segments state for current video and playlist videos const [currentSegments, setCurrentSegments] = React.useState([]); const [playlistSegments, setPlaylistSegments] = React.useState>({}); const [currentProgress, setCurrentProgress] = React.useState(0); // Per-user unlock state const [userUnlocks, setUserUnlocks] = React.useState>(new Set()); // Set of unlocked videoIds const [videoInstantAccess, setVideoInstantAccess] = React.useState>({}); // videoId -> instantAccess const [unlockingVideo, setUnlockingVideo] = React.useState(null); // videoId being unlocked // inside your videoplayer component const videoRef = React.useRef(null); const tracker = useWatchTracker(videoRef, { sendIntervalMs: 5000 }); // Fetch segments for current video React.useEffect(() => { if (!videoId) return; const fetchSegments = async () => { try { const res = await fetch(`/api/progress/segments?videoId=${videoId}`); if (res.ok) { const data = await res.json(); setCurrentSegments(data.segments ?? []); } const progressRes = await fetch(`/api/progress?videoId=${videoId}`); if (progressRes.ok) { const data = await progressRes.json(); setCurrentProgress(data.percent ?? 0); } } catch (err) { console.error('Failed to fetch segments:', err); } }; fetchSegments(); // Refresh segments every 5 seconds const interval = setInterval(fetchSegments, 5000); return () => clearInterval(interval); }, [videoId]); // Fetch segments for all playlist videos React.useEffect(() => { if (!playlist?.videos || playlist.videos.length === 0) return; const fetchPlaylistSegments = async () => { const segments: Record = {}; const instantAccess: Record = {}; for (const video of playlist.videos) { // Fetch segments try { const res = await fetch(`/api/progress/segments?videoId=${video.id}`); if (res.ok) { const data = await res.json(); segments[video.id] = data.segments ?? []; } } catch (err) { console.error(`Failed to fetch segments for video ${video.id}:`, err); } // Track instantAccess status from video object instantAccess[video.id] = (video as any).instantAccess ?? false; } setPlaylistSegments(segments); setVideoInstantAccess(instantAccess); }; fetchPlaylistSegments(); }, [playlist?.videos]); // Fetch user's VideoUnlock records React.useEffect(() => { const fetchUserUnlocks = async () => { try { const res = await fetch('/api/user/unlocks'); if (res.ok) { const data = await res.json(); const unlockedVideoIds = new Set((data.unlocks?.map((u: any) => u.videoId) ?? []) as string[]); setUserUnlocks(unlockedVideoIds); } } catch (err) { console.error('Failed to fetch user unlocks:', err); } }; fetchUserUnlocks(); }, []); React.useEffect(() => { if (!video) return; let didCancel = false; let poll: number | null = null; const startWhenReady = () => { if (didCancel) return; const el = videoRef.current; const duration = (video.durationSec ?? Math.floor(el?.duration ?? 0)) || 0; if (el) { tracker.startAutoSend({ videoId: String(video.id), playlistId: playlist?.id, duration, }); } else { // poll until the video element mounts (should be quick) poll = window.setInterval(() => { if (videoRef.current) { if (poll) { window.clearInterval(poll); poll = null; } tracker.startAutoSend({ videoId: String(video.id), playlistId: playlist?.id, duration: (video.durationSec ?? Math.floor(videoRef.current?.duration ?? 0)) || 0, }); } }, 150) as unknown as number; } }; startWhenReady(); return () => { didCancel = true; if (poll) { window.clearInterval(poll); poll = null; } const duration = video?.durationSec ?? Math.floor(videoRef.current?.duration ?? 0); // send final snapshot tracker.stopAutoSend({ sendFinal: true, videoId: String(video?.id ?? ''), playlistId: playlist?.id, duration }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [video?.id, playlist?.id]); const current: VideoFromApi = (video as VideoFromApi) ?? { id: 'loading', title: 'Loading…', duration: '0:00', thumbnail: '/01.jpg', url: '', }; const fmt = (v: VideoFromApi) => { if (v.duration) return v.duration; if (!v.durationSec && v.durationSec !== 0) return ''; const mins = Math.floor((v.durationSec ?? 0) / 60); const secs = Math.floor((v.durationSec ?? 0) % 60) .toString() .padStart(2, '0'); return `${mins}:${secs}`; }; const handleUnlock = async (v: VideoFromApi) => { console.log('unlock request for', v.id); // Prevent multiple unlock attempts if (unlockingVideo === v.id) return; setUnlockingVideo(v.id); try { // Can't unlock if globally locked if ((v as any).locked) { console.log('Video is globally locked, cannot unlock'); return; } // Can't unlock if already unlocked or has instant access if ((v as any).instantAccess || userUnlocks.has(v.id)) { console.log('Video already accessible'); router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`); return; } // Check if previous video in sequence is completed if (!playlist?.videos) return; const videoIndex = (v as any).index; if (videoIndex <= 0) { // First video should always be accessible router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`); return; } // Find previous video const previousVideo = playlist.videos.find((pv: any) => pv.index === videoIndex - 1); if (!previousVideo) { console.log('Previous video not found'); return; } // Check if previous video is completed const progressRes = await fetch(`/api/progress?videoId=${previousVideo.id}`); if (!progressRes.ok) { console.error('Failed to fetch previous video progress'); alert('Error checking unlock requirements. Please try again.'); return; } const progressData = await progressRes.json(); const isCompleted = progressData.percent >= 90; // Consider 90% as completed if (!isCompleted) { console.log('Previous video not completed yet - need', Math.ceil(90 - progressData.percent), '% more'); // TODO: Show toast notification explaining unlock requirements alert(`You need to complete ${Math.ceil(90 - progressData.percent)}% more of the previous video to unlock this one.`); return; } // Previous video is completed, unlock this video const unlockRes = await fetch('/api/user/unlocks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoId: v.id }), }); if (unlockRes.ok) { // Update local state setUserUnlocks(prev => new Set(prev).add(v.id)); console.log('Video unlocked successfully'); // Redirect to the unlocked video router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`); } else { const errorData = await unlockRes.json().catch(() => ({ error: 'Unknown error' })); console.error('Failed to unlock video:', errorData.error); alert(`Failed to unlock video: ${errorData.error}`); } } catch (err) { console.error('Error checking unlock conditions:', err); alert('Error checking if video can be unlocked. Please try again.'); } finally { setUnlockingVideo(null); } }; const [isLiked, setIsLiked] = React.useState(false); const { data: session } = useSession(); const isAdmin = (session as any)?.user?.role === 'admin' || (session as any)?.user?.role === 'superadmin'; React.useEffect(() => { // Check if current video is liked on load if (current.id) { const checkLike = async () => { try { const res = await fetch(`/api/likes?videoId=${current.id}`); if (res.ok) { const data = await res.json(); setIsLiked(data.isLiked); } } catch (err) { console.error('Failed to check if video is liked', err); } }; checkLike(); } }, [current.id]); const handleLike = async () => { const newLikedState = !isLiked; setIsLiked(newLikedState); try { const res = await fetch('/api/likes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ videoId: current.id, isLiked: newLikedState, }), }); if (!res.ok) { // Revert on error setIsLiked(!newLikedState); console.error('Failed to toggle like'); } } catch (err) { // Revert on error setIsLiked(!newLikedState); console.error('Error toggling like', err); } }; return (
{videoLoading ? (
Loading video…
) : current.url ? ( // HLS player with MP4 fallback // Prefers HLS if transcoding is complete, falls back to MP4 ) : (
Video not available
)}
{current.uploader?.image && (
{current.uploader.name
)}
{current.title} {current.uploader?.name && (

{current.uploader.name}

)} {current.createdAt && (

Uploaded {formatDistanceToNow(new Date(current.createdAt), { addSuffix: true })}

)}
{isAdmin && ( )}
{current.description && (

{current.description}

)}
{/*

Notes

Keep lesson notes, transcript links, or chapter markers here.

Resources

  • Model files
  • Reference sheets
  • Assignments
*/}
); }