786 lines
28 KiB
TypeScript
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>
|
|
);
|
|
}
|