Files
Vault/app/videoplayer/page.tsx
T
twotalesanimation c98b5c5692
Deploy Vault CMS / deploy (push) Failing after 5s
Load watch progress in one request on dashboard and player
Add GET /api/progress/all, which returns the signed-in user's progress
and merged watch segments for every video (or one playlist) using two
queries.

- Dashboard: replaces two sequential requests per video with a single
  request on load
- Player sidebar: replaces one request per playlist video with a single
  request per playlist

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:48:35 +02:00

690 lines
25 KiB
TypeScript

'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';
import { toast } from 'sonner';
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;
};
type ProgressResult = {
progress?: { percent?: number };
segments?: WatchSegment[];
unlockedNext?: { id: string; title: string } | null;
};
type TrackedVideo = { videoId: string; durationSec: number | null };
/**
* Records which parts of a video were actually played and reports them to
* /api/progress.
*
* It samples the <video> element on a timer rather than relying on media
* events, so it keeps working when the element is swapped (one per video) or
* when playback starts before anything else on the page has settled. A sample
* only counts as watched when the playhead moved forward by no more than the
* wall-clock time that passed, which excludes seeks and scrubbing.
*/
export function useWatchTracker(
videoRef: React.RefObject<HTMLVideoElement | null>,
opts: {
sendIntervalMs?: number;
sampleIntervalMs?: number;
onResult?: (videoId: string, result: ProgressResult) => void;
} = {}
) {
const sendIntervalMs = opts.sendIntervalMs ?? 5000;
const sampleIntervalMs = opts.sampleIntervalMs ?? 500;
const onResultRef = React.useRef(opts.onResult);
React.useEffect(() => {
onResultRef.current = opts.onResult;
});
const activeRef = React.useRef<TrackedVideo | null>(null);
const rangesRef = React.useRef<Array<[number, number]>>([]);
const lastSampleRef = React.useRef<{ el: HTMLVideoElement; time: number; wall: number } | null>(null);
const lastPosRef = React.useRef(0);
const elementDurationRef = React.useRef(0);
const dirtyRef = React.useRef(false);
const reset = React.useCallback((next: TrackedVideo | null) => {
activeRef.current = next;
rangesRef.current = [];
lastSampleRef.current = null;
lastPosRef.current = 0;
elementDurationRef.current = 0;
dirtyRef.current = false;
}, []);
const sample = React.useCallback(() => {
const el = videoRef.current;
if (!activeRef.current || !el || typeof el.currentTime !== 'number') {
lastSampleRef.current = null;
return;
}
const prev = lastSampleRef.current;
const now = el.currentTime;
const wall = performance.now();
lastSampleRef.current = { el, time: now, wall };
// First sample of a new element: nothing to compare against yet.
if (!prev || prev.el !== el) return;
lastPosRef.current = now;
if (Number.isFinite(el.duration) && el.duration > 0) {
elementDurationRef.current = el.duration;
}
if (el.seeking) return;
const delta = now - prev.time;
if (delta <= 0) return; // paused, or jumped backwards
// Real playback cannot outrun the clock (timers may fire late in
// background tabs, hence wall-clock time rather than the interval).
const elapsedSec = (wall - prev.wall) / 1000;
const maxAdvance = elapsedSec * Math.max(1, el.playbackRate || 1) + 0.75;
if (delta > maxAdvance) return; // jumped forwards
const ranges = rangesRef.current;
const last = ranges[ranges.length - 1];
if (last && Math.abs(last[1] - prev.time) < 0.01) {
last[1] = now; // still in the same stretch of playback
} else {
ranges.push([prev.time, now]);
}
dirtyRef.current = true;
}, [videoRef]);
const flush = React.useCallback(async (keepalive = false) => {
const active = activeRef.current;
if (!active || !dirtyRef.current || rangesRef.current.length === 0) return;
const duration = active.durationSec || Math.floor(elementDurationRef.current);
if (!duration || duration <= 0) return; // metadata not loaded yet; try again next tick
// Everything played this session is resent each time; the server merges,
// so a dropped request loses nothing.
const ranges = rangesRef.current.map(([s, e]) => [s, e]);
dirtyRef.current = false;
try {
const res = await fetch('/api/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive,
body: JSON.stringify({
videoId: active.videoId,
ranges,
lastPos: Math.floor(lastPosRef.current),
duration,
}),
});
const stillActive = activeRef.current?.videoId === active.videoId;
if (!res.ok) {
if (res.status === 404) {
// Video was deleted: stop tracking it.
if (stillActive) reset(null);
} else if (stillActive) {
dirtyRef.current = true;
}
return;
}
const json = await res.json().catch(() => null);
if (json) onResultRef.current?.(active.videoId, json);
} catch {
if (activeRef.current?.videoId === active.videoId) dirtyRef.current = true;
}
}, [reset]);
/** Begin tracking a video. Anything recorded for a previous video is dropped. */
const start = React.useCallback((video: TrackedVideo) => reset(video), [reset]);
/** Send what is left for the current video, then stop tracking it. */
const stop = React.useCallback(() => {
void flush(true);
reset(null);
}, [flush, reset]);
React.useEffect(() => {
const sampleTimer = window.setInterval(sample, sampleIntervalMs);
const sendTimer = window.setInterval(() => void flush(), sendIntervalMs);
// Leaving or hiding the page: get the latest progress out while we can.
const flushNow = () => {
sample();
void flush(true);
};
const onVisibility = () => {
if (document.visibilityState === 'hidden') flushNow();
};
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pagehide', flushNow);
return () => {
window.clearInterval(sampleTimer);
window.clearInterval(sendTimer);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('pagehide', flushNow);
};
}, [sample, flush, sampleIntervalMs, sendIntervalMs]);
return { start, stop };
}
export default function Page() {
const search = useSearchParams();
const router = useRouter();
const playlistIdParam = search.get('playlistId') ?? undefined;
const videoId = search.get('videoId') ?? undefined;
const { video, isLoading: videoLoading, error: videoError } = useVideo(videoId);
// Links from watch history and liked videos carry no playlist; use the video's own.
const { playlist, isLoading: playlistLoading } = usePlaylist(
playlistIdParam ?? (video as any)?.playlistId ?? undefined
);
// Segments state for current video and playlist videos
const [currentSegments, setCurrentSegments] = React.useState<WatchSegment[]>([]);
const [playlistSegments, setPlaylistSegments] = React.useState<Record<string, WatchSegment[]>>({});
const [currentProgress, setCurrentProgress] = React.useState(0);
// Per-user unlock state
const [userUnlocks, setUserUnlocks] = React.useState<Set<string>>(new Set()); // Set of unlocked videoIds
const [unlockingVideo, setUnlockingVideo] = React.useState<string | null>(null); // videoId being unlocked
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const trackedVideoIdRef = React.useRef<string | null>(null);
const userUnlocksRef = React.useRef(userUnlocks);
React.useEffect(() => {
userUnlocksRef.current = userUnlocks;
}, [userUnlocks]);
// Every progress update returns the server's view: percentage, merged
// segments, and the next video if this one just unlocked it.
const handleProgressResult = React.useCallback((id: string, result: ProgressResult) => {
const segments = result.segments;
if (segments) {
setPlaylistSegments((prev) => ({ ...prev, [id]: segments }));
}
if (id === trackedVideoIdRef.current) {
if (segments) setCurrentSegments(segments);
if (typeof result.progress?.percent === 'number') setCurrentProgress(result.progress.percent);
}
const unlocked = result.unlockedNext;
if (unlocked && !userUnlocksRef.current.has(unlocked.id)) {
setUserUnlocks((prev) => new Set(prev).add(unlocked.id));
toast.success(`Unlocked: ${unlocked.title}`);
}
}, []);
const tracker = useWatchTracker(videoRef, { onResult: handleProgressResult });
const { start: startTracking, stop: stopTracking } = tracker;
// Load saved progress for the current video. Live updates then arrive with
// each progress save, so there is no need to poll.
React.useEffect(() => {
if (!videoId) return;
let cancelled = false;
const fetchSegments = async () => {
try {
const [res, progressRes] = await Promise.all([
fetch(`/api/progress/segments?videoId=${videoId}`),
fetch(`/api/progress?videoId=${videoId}`),
]);
if (cancelled) return;
if (res.ok) {
const data = await res.json();
if (!cancelled) setCurrentSegments(data.segments ?? []);
}
if (progressRes.ok) {
const data = await progressRes.json();
if (!cancelled) setCurrentProgress(data.percent ?? 0);
}
} catch (err) {
console.error('Failed to fetch segments:', err);
}
};
setCurrentSegments([]);
setCurrentProgress(0);
fetchSegments();
return () => {
cancelled = true;
};
}, [videoId]);
// Fetch watched segments for every video in the playlist in one request
const loadedPlaylistId: string | undefined = playlist?.id;
React.useEffect(() => {
if (!loadedPlaylistId) return;
let cancelled = false;
const fetchPlaylistSegments = async () => {
try {
const res = await fetch(`/api/progress/all?playlistId=${encodeURIComponent(loadedPlaylistId)}`);
if (!res.ok || cancelled) return;
const data = await res.json();
const segments: Record<string, WatchSegment[]> = {};
for (const [id, entry] of Object.entries<any>(data.progress ?? {})) {
segments[id] = entry.segments ?? [];
}
// Keep anything newer that arrived from a progress save in the meantime.
if (!cancelled) setPlaylistSegments((prev) => ({ ...segments, ...prev }));
} catch (err) {
console.error('Failed to fetch playlist progress:', err);
}
};
setPlaylistSegments({});
fetchPlaylistSegments();
return () => {
cancelled = true;
};
}, [loadedPlaylistId]);
// 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 = (data.unlocks?.map((u: any) => u.videoId) ?? []) as string[];
// Merge, in case a progress save unlocked something while this loaded.
setUserUnlocks((prev) => new Set([...prev, ...unlockedVideoIds]));
}
} catch (err) {
console.error('Failed to fetch user unlocks:', err);
}
};
fetchUserUnlocks();
}, []);
// Track whichever video is loaded; switching videos flushes the old one first.
const loadedVideoId = video?.id ? String(video.id) : null;
const loadedVideoDuration: number | null = video?.durationSec ?? null;
React.useEffect(() => {
if (!loadedVideoId) return;
trackedVideoIdRef.current = loadedVideoId;
startTracking({ videoId: loadedVideoId, durationSec: loadedVideoDuration });
return () => {
stopTracking();
trackedVideoIdRef.current = null;
};
}, [loadedVideoId, loadedVideoDuration, startTracking, stopTracking]);
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}`;
};
// A video is open when it is the first in the playlist, marked instant
// access, or unlocked for this user. The lecturer's lock overrides all three.
const firstVideoId: string | undefined = playlist?.videos?.[0]?.id;
const isAccessible = (v: VideoFromApi) =>
!v.locked &&
(!!(v as any).instantAccess || v.id === firstVideoId || userUnlocks.has(v.id));
const openVideo = (v: VideoFromApi) => {
router.push(`/videoplayer?playlistId=${playlist?.id}&videoId=${v.id}`);
};
// The server decides whether the unlock is earned and explains when it is not.
const handleUnlock = async (v: VideoFromApi) => {
if (unlockingVideo === v.id || v.locked) return;
setUnlockingVideo(v.id);
try {
const res = await fetch('/api/user/unlocks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: v.id }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
toast.error(data?.error ?? 'Could not unlock this video. Please try again.');
return;
}
setUserUnlocks((prev) => new Set(prev).add(v.id));
openVideo(v);
} catch (err) {
console.error('Error unlocking video:', err);
toast.error('Could not unlock this video. 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 (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 gap-4 p-4 lg:p-6">
<div className="flex-1 min-w-0">
<Card className="mb-4">
<CardContent className="p-0">
<div className="relative w-full overflow-hidden">
{videoLoading ? (
<div className="flex items-center justify-center h-80">Loading video…</div>
) : current.url ? (
// HLS player with MP4 fallback
// Prefers HLS if transcoding is complete, falls back to MP4
<HlsPlayer
src={current.videoUrls?.hlsUrl || current.url}
fallbackSrc={current.url}
videoId={current.id}
ref={videoRef}
/>
) : (
<div className="flex items-center justify-center h-80 px-6 text-center">
{videoError?.message && !/^HTTP \d+$/.test(videoError.message)
? videoError.message
: 'Video not available'}
</div>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col gap-4">
<div className="flex items-start justify-between w-full gap-4">
<div className="flex-1 flex gap-3">
{current.uploader?.image && (
<div className="relative w-12 h-12 rounded-full overflow-hidden shrink-0">
<Image
src={current.uploader.image}
alt={current.uploader.name ?? 'Uploader'}
fill
unoptimized
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
<CardTitle className="text-lg">{current.title}</CardTitle>
{current.uploader?.name && (
<p className="text-sm text-muted-foreground mt-1">
{current.uploader.name}
</p>
)}
{current.createdAt && (
<p className="text-xs text-muted-foreground">
Uploaded {formatDistanceToNow(new Date(current.createdAt), { addSuffix: true })}
</p>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
{isAdmin && (
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/admin/videos/${current.id}/edit`)}
>
<Edit className="w-4 h-4" />
Edit
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleLike}
className={isLiked ? 'text-red-500' : ''}
>
Like
<Heart
className="w-5 h-5"
fill={isLiked ? 'currentColor' : 'none'}
/>
</Button>
</div>
</div>
{current.description && (
<p className="text-sm text-muted-foreground w-full">
{current.description}
</p>
)}
<div className="w-full mt-4 pt-4 border-t">
<SegmentedProgressBar
segments={currentSegments}
duration={current.durationSec ?? 1}
percent={currentProgress}
height="md"
showTooltip={true}
/>
</div>
</CardFooter>
</Card>
<Card>
<CardContent>
<CommentsSection videoId={current.id} />
</CardContent>
</Card>
{/* <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardContent>
<h3 className="font-medium">Notes</h3>
<p className="mt-2 text-sm text-muted-foreground">
Keep lesson notes, transcript links, or chapter markers here.
</p>
</CardContent>
</Card>
<Card>
<CardContent>
<h3 className="font-medium">Resources</h3>
<ul className="mt-2 text-sm text-muted-foreground list-disc ml-5">
<li>Model files</li>
<li>Reference sheets</li>
<li>Assignments</li>
</ul>
</CardContent>
</Card>
</div> */}
</div>
<aside className="w-80 hidden lg:block">
<h2 className="text-sm font-semibold mb-3">Playlist</h2>
<div className="flex flex-col gap-3">
{playlistLoading ? (
<div>Loading playlist…</div>
) : (
playlist?.videos?.map((v: VideoFromApi) => {
const active = String(v.id) === String(current.id);
const accessible = isAccessible(v);
return (
<div
key={v.id}
className={`relative rounded-md border p-2 flex flex-col gap-2 cursor-pointer transition
${
active ? 'border-primary bg-muted' : 'border-transparent hover:border-border'
}`}
onClick={() => {
if (accessible) openVideo(v);
}}
>
<div className="flex items-center gap-3">
<div className="relative w-20 h-12 shrink-0 overflow-hidden rounded">
<img src={v.thumbnail ?? '/01.jpg'} alt={v.title} className="w-full h-full object-cover" loading="lazy" />
{!accessible ? (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<Lock className="w-5 h-5 text-white" />
</div>
) : null}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<div className="text-sm font-medium truncate">{v.title}</div>
<Badge>{fmt(v)}</Badge>
</div>
<div className="text-xs text-muted-foreground truncate mt-1">
{active ? 'Now playing' : accessible ? 'Available' : 'Locked'}
</div>
</div>
{!accessible ? (
<div className="ml-2">
<Button
size="sm"
variant={v.locked ? "secondary" : "default"}
disabled={v.locked || unlockingVideo === v.id}
onClick={(e) => {
e.stopPropagation();
handleUnlock(v);
}}
>
{unlockingVideo === v.id ? 'Unlocking...' : v.locked ? 'Locked' : 'Unlock'}
</Button>
</div>
) : null}
</div>
<SegmentedProgressBar
segments={playlistSegments[v.id] ?? []}
duration={v.durationSec ?? 1}
percent={0}
height="sm"
showTooltip={false}
/>
</div>
);
})
)}
</div>
</aside>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}