diff --git a/app/(dashboard)/playlist/PlaylistClient.tsx b/app/(dashboard)/playlist/PlaylistClient.tsx index 207b3c9..86216fa 100644 --- a/app/(dashboard)/playlist/PlaylistClient.tsx +++ b/app/(dashboard)/playlist/PlaylistClient.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo, startTransition } from "react"; import Image from "next/image"; import Link from "next/link"; import { ReviewPlayer, type ReviewPlayerRef } from "@/components/player/ReviewPlayer"; +import { SequencePlayer, type SequenceClip } from "@/components/player/SequencePlayer"; import { useReviewStore } from "@/hooks/use-review-player"; import { Select, @@ -26,6 +27,8 @@ import { ExternalLink, ShieldCheck, Send, + ListVideo, + X, } from "lucide-react"; interface Project { @@ -42,6 +45,7 @@ interface PlaylistVersion { thumbnailUrl: string | null; posterUrl: string | null; fps: number; + duration?: number | null; frameCount?: number | null; hqFileUrl?: string | null; handleHead?: number | null; @@ -91,6 +95,10 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) { const [activeShot, setActiveShot] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isActioning, setIsActioning] = useState(false); + /** Shots checked for the seamless playlist, by shot id. */ + const [selected, setSelected] = useState>(new Set()); + const [sequenceMode, setSequenceMode] = useState(false); + const lastChecked = useRef(null); const canApprove = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(userRole); @@ -117,6 +125,9 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) { if (cancelled) return; const list: PlaylistShot[] = data.shots ?? []; setShots(list); + setSelected(loadSelection(projectId, list)); + lastChecked.current = null; + setSequenceMode(false); // Auto-select the first shot, or keep the current one if it still exists setActiveShot((prev) => { const stillExists = prev && list.some((s) => s.id === prev.id); @@ -139,9 +150,78 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) { const handleSelectShot = useCallback((shot: PlaylistShot) => { reset(); + setSequenceMode(false); setActiveShot(shot); }, [reset]); + const updateSelection = useCallback( + (next: Set) => { + setSelected(next); + if (projectId) saveSelection(projectId, next); + if (next.size === 0) setSequenceMode(false); + }, + [projectId] + ); + + /** Toggle one shot; shift-click sets every shot between it and the last one checked. */ + const toggleChecked = (shot: PlaylistShot, shift: boolean) => { + const next = new Set(selected); + const on = !selected.has(shot.id); + const from = lastChecked.current ? shots.findIndex((s) => s.id === lastChecked.current) : -1; + const to = shots.findIndex((s) => s.id === shot.id); + const range = shift && from >= 0 ? shots.slice(Math.min(from, to), Math.max(from, to) + 1) : [shot]; + for (const s of range) { + if (on) next.add(s.id); + else next.delete(s.id); + } + lastChecked.current = shot.id; + updateSelection(next); + }; + + const setGroupChecked = (group: PlaylistShot[], on: boolean) => { + const next = new Set(selected); + for (const s of group) { + if (on) next.add(s.id); + else next.delete(s.id); + } + updateSelection(next); + }; + + // The playlist, in edit order. Keyed on the versions it plays so a refresh + // (e.g. after approving a shot) doesn't restart playback. + const clipKey = shots + .filter((s) => selected.has(s.id)) + .map((s) => s.latestVersion.id) + .join(","); + const clips = useMemo( + () => + shots + .filter((s) => selected.has(s.id)) + .map((s) => ({ + id: s.latestVersion.id, + label: s.shotCode, + sublabel: `v${String(s.latestVersion.versionNumber).padStart(3, "0")}`, + url: s.latestVersion.fileUrl, + hqUrl: s.latestVersion.hqFileUrl, + fps: s.latestVersion.fps, + duration: s.latestVersion.duration, + handles: s.latestVersion, + })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [clipKey] + ); + + const handleClipChange = useCallback( + (clip: SequenceClip) => { + // A transition, so re-rendering the page (shot list and all) yields to + // the video: rendered in one go, it dropped the frame after every cut. + startTransition(() => { + setActiveShot(shots.find((s) => s.latestVersion.id === clip.id) ?? null); + }); + }, + [shots] + ); + const refreshShots = useCallback(() => { if (!projectId) return; fetch(`/api/playlist?projectId=${projectId}`) @@ -220,6 +300,33 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) { + {selected.size > 0 && ( + sequenceMode ? ( + + ) : ( + + ) + )} + {activeShot && (
{activeShot.shotCode} @@ -272,7 +379,9 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
{/* Player */}
- {activeShot ? ( + {sequenceMode && clips.length > 0 ? ( + + ) : activeShot ? ( Shots {shots.length > 0 && ( - {shots.length} +
+ {selected.size > 0 ? ( + + ) : ( + + )} + + {selected.size > 0 ? `${selected.size}/${shots.length}` : shots.length} + +
)}
@@ -327,16 +455,25 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) {
{episodeGroups.map(([episode, episodeShots]) => (
-
+
+ {episodeShots.map((shot) => ( toggleChecked(shot, shift)} onClick={() => handleSelectShot(shot)} /> ))} @@ -350,6 +487,8 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) { key={shot.id} shot={shot} isActive={activeShot?.id === shot.id} + isChecked={selected.has(shot.id)} + onToggle={(shift) => toggleChecked(shot, shift)} onClick={() => handleSelectShot(shot)} /> ))} @@ -365,25 +504,45 @@ export function PlaylistClient({ projects, userRole }: PlaylistClientProps) { function ShotThumbnail({ shot, isActive, + isChecked, + onToggle, onClick, }: { shot: PlaylistShot; isActive: boolean; + isChecked: boolean; + onToggle: (shift: boolean) => void; onClick: () => void; }) { const thumb = shot.latestVersion.thumbnailUrl ?? shot.latestVersion.posterUrl ?? shot.thumbnailUrl; const ApprovalIcon = APPROVAL_ICONS[shot.latestVersion.approvalStatus] ?? Clock; return ( -
- +
); } + +const selectionKey = (projectId: string) => `playlist:selection:${projectId}`; + +/** The checked shots remembered for this project, minus any no longer listed. */ +function loadSelection(projectId: string, shots: PlaylistShot[]): Set { + try { + const ids: unknown = JSON.parse(localStorage.getItem(selectionKey(projectId)) ?? "[]"); + if (!Array.isArray(ids)) return new Set(); + const listed = new Set(shots.map((s) => s.id)); + return new Set(ids.filter((id): id is string => typeof id === "string" && listed.has(id))); + } catch { + return new Set(); + } +} + +function saveSelection(projectId: string, selected: Set) { + try { + localStorage.setItem(selectionKey(projectId), JSON.stringify([...selected])); + } catch { + // Storage unavailable: the selection just isn't remembered. + } +} diff --git a/app/api/playlist/route.ts b/app/api/playlist/route.ts index a84e5df..a31e460 100644 --- a/app/api/playlist/route.ts +++ b/app/api/playlist/route.ts @@ -50,6 +50,7 @@ export async function GET(req: NextRequest) { thumbnailUrl: true, posterUrl: true, fps: true, + duration: true, frameCount: true, hqFileUrl: true, handleHead: true, @@ -90,6 +91,7 @@ export async function GET(req: NextRequest) { thumbnailUrl: latest.thumbnailUrl, posterUrl: latest.posterUrl, fps: latest.fps, + duration: latest.duration, frameCount: latest.frameCount, hqFileUrl: latest.hqFileUrl, handleHead: latest.handleHead, diff --git a/components/player/SequencePlayer.tsx b/components/player/SequencePlayer.tsx new file mode 100644 index 0000000..a5c507e --- /dev/null +++ b/components/player/SequencePlayer.tsx @@ -0,0 +1,822 @@ +"use client"; + +/** + * Seamless playlist playback: plays the cut of each clip back to back — no + * slate, no handles — without the gap of loading one video after another. + * + * Two