a834caa511
Deploy Vault CMS / deploy (push) Failing after 38s
Watch progress and unlocking: - Player samples the playhead and reports the ranges actually played, so skipped sections earn no credit and early plays are not lost - Server merges ranges into compact segment rows; progress never regresses - Single 80% unlock rule enforced server-side (lib/watch-progress.ts); the Unlock button and automatic unlock now agree - Lock state and progress bar update live from each progress save - Playlist is derived from the video, so unlocks work for shared playlists and for links from watch history and liked videos - First/previous video decided by playlist position, not index numbers Access control: - Video API and HLS/MP4 routes require a signed-in viewer who is enrolled and has the video unlocked; thumbnails require sign-in - Enrolment sync reads course codes from the allow-list, not the request - /api/admin/meta is admin-only - Account status, role and levels are re-checked from the database during a session, so deactivated students lose access within a minute Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
688 lines
24 KiB
TypeScript
688 lines
24 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 segments for all playlist videos
|
|
React.useEffect(() => {
|
|
if (!playlist?.videos || playlist.videos.length === 0) return;
|
|
|
|
const fetchPlaylistSegments = async () => {
|
|
const segments: Record<string, WatchSegment[]> = {};
|
|
|
|
for (const video of playlist.videos) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Keep anything newer that arrived from a progress save in the meantime.
|
|
setPlaylistSegments((prev) => ({ ...segments, ...prev }));
|
|
};
|
|
|
|
setPlaylistSegments({});
|
|
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 = (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>
|
|
);
|
|
}
|