Files
twotalesanimation 81ad7e4ea9 Initial commit
2026-06-11 10:46:09 +02:00

786 lines
28 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';
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<HTMLVideoElement | null>,
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<Array<[number, number]>>([]);
const lastTimeRef = React.useRef<number | null>(null);
const currentRangeStartRef = React.useRef<number | null>(null);
const sendTimerRef = React.useRef<number | null>(null);
const commitTimerRef = React.useRef<number | null>(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<string, any> }) {
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<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 [videoInstantAccess, setVideoInstantAccess] = React.useState<Record<string, boolean>>({}); // videoId -> instantAccess
const [unlockingVideo, setUnlockingVideo] = React.useState<string | null>(null); // videoId being unlocked
// inside your videoplayer component
const videoRef = React.useRef<HTMLVideoElement | null>(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<string, WatchSegment[]> = {};
const instantAccess: Record<string, boolean> = {};
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 (
<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">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 watchedPercent = 0; // Replace with actual watched % from your state/API
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={() => {
// Check if video is accessible based on the logic:
// 1. If locked at schema level, always deny
if ((v as any).locked) return;
// 2. If instantAccess, always allow
if ((v as any).instantAccess) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
return;
}
// 3. If first video (index 0), always allow
if ((v as any).index === 0) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
return;
}
// 4. Otherwise, check if user has unlocked it
if (userUnlocks.has(v.id)) {
router.push(`/videoplayer?playlistId=${playlist.id}&videoId=${v.id}`);
}
}}
>
<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" />
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
<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">
{(v as any).locked ? 'Locked' : (v as any).instantAccess || (v as any).index === 0 ? 'Available' : userUnlocks.has(v.id) ? 'Available' : active ? 'Now playing' : 'Locked'}
</div>
</div>
{(v as any).locked || (!(v as any).instantAccess && (v as any).index !== 0 && !userUnlocks.has(v.id)) ? (
<div className="ml-2">
<Button
size="sm"
variant={(v as any).locked ? "secondary" : "default"}
disabled={(v as any).locked || unlockingVideo === v.id}
onClick={(e) => {
e.stopPropagation();
handleUnlock(v);
}}
>
{unlockingVideo === v.id ? 'Unlocking...' : (v as any).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>
);
}