326d8cbefa
Deploy / deploy (push) Failing after 1m25s
On mount, the loop-toggle effect preloaded clip 2 into the slot the first goTo was loading clip 1 into, cancelling it, so nothing ever went on screen. Dev's StrictMode double-mount re-ran the first load and hid it. - preloadNext never loads over a slot a goTo is loading into - the loop effect only reloads the standby on an actual toggle - pausing or jumping mid pre-roll no longer re-parks a clip that a jump has since put on screen Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
843 lines
30 KiB
TypeScript
843 lines
30 KiB
TypeScript
"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 <video> elements are stacked. While one plays, the other has the next
|
|
* clip loaded and parked on its cut in-point; on the cut out-point they swap.
|
|
* The out-point is caught with requestVideoFrameCallback (per presented
|
|
* frame) where the browser has it, else every animation frame.
|
|
*
|
|
* The media is never changed: the cut comes from the version's handle and
|
|
* slate metadata (lib/handles.ts), as in the review player.
|
|
*/
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { cutSpan, type FrameSpan, type VersionHandles } from "@/lib/handles";
|
|
import { durationToFrameCount, frameToTime, frameToTimecode, timeToFrame } from "@/lib/frame-utils";
|
|
import {
|
|
ChevronFirst,
|
|
ChevronLast,
|
|
Loader2,
|
|
Pause,
|
|
Play,
|
|
Repeat,
|
|
Volume2,
|
|
VolumeX,
|
|
} from "lucide-react";
|
|
|
|
export interface SequenceClip {
|
|
/** Version id. */
|
|
id: string;
|
|
label: string;
|
|
sublabel?: string;
|
|
url: string;
|
|
hqUrl?: string | null;
|
|
fps: number;
|
|
/** Media duration in seconds, if known — sizes the timeline before loading. */
|
|
duration?: number | null;
|
|
handles: VersionHandles;
|
|
}
|
|
|
|
interface SequencePlayerProps {
|
|
clips: SequenceClip[];
|
|
/** Called with the clip now on screen. */
|
|
onClipChange?: (clip: SequenceClip, index: number) => void;
|
|
}
|
|
|
|
type Slot = 0 | 1;
|
|
|
|
interface Loaded {
|
|
index: number;
|
|
url: string;
|
|
/** Media frame it is parked on, once seeked. */
|
|
parkedAt?: number;
|
|
/** Its cut in-point, once the media length is known. */
|
|
cutIn?: number;
|
|
}
|
|
|
|
/** Seek a quarter frame in so float error can't land on the previous frame. */
|
|
const SEEK_BIAS = 0.25;
|
|
|
|
/**
|
|
* The next clip waits this many frames before its cut in (handle or slate —
|
|
* hidden anyway) so it's already running, past the stutter of a video
|
|
* starting up, when it comes on screen. Clips with less lead-in (a 1-frame
|
|
* slate, no handles) pre-roll what they have.
|
|
*/
|
|
const PREROLL_FRAMES = 4;
|
|
|
|
/**
|
|
* How long before its pre-roll frames the next clip must start. Chrome counts
|
|
* the parked frame as shown the moment play() is called, so ~0; calibrated at
|
|
* every cut for browsers that differ.
|
|
*/
|
|
const INITIAL_START_LATENCY_MS = 0;
|
|
|
|
/**
|
|
* Hold off loading the clip after next until the cut has settled: loading
|
|
* one video while another starts playing made the incoming clip drop a frame.
|
|
*/
|
|
const PRELOAD_DELAY_MS = 300;
|
|
|
|
function estimatedSpan(clip: SequenceClip): FrameSpan {
|
|
const total =
|
|
clip.duration && clip.duration > 0
|
|
? durationToFrameCount(clip.duration, clip.fps)
|
|
: (clip.handles.frameCount ?? 0) + 1;
|
|
return cutSpan(clip.handles, total) ?? { start: 0, end: Math.max(1, total) };
|
|
}
|
|
|
|
export function SequencePlayer({ clips, onClipChange }: SequencePlayerProps) {
|
|
const videoRefs = [useRef<HTMLVideoElement>(null), useRef<HTMLVideoElement>(null)];
|
|
const loaded = useRef<[Loaded | null, Loaded | null]>([null, null]);
|
|
/** Real spans, filled in as each clip's metadata arrives. */
|
|
const spans = useRef(new Map<string, FrameSpan>());
|
|
|
|
const [active, setActive] = useState<Slot>(0);
|
|
const activeRef = useRef<Slot>(0);
|
|
const [index, setIndex] = useState(0);
|
|
const indexRef = useRef(0);
|
|
const [frame, setFrame] = useState(0); // frame within the active media
|
|
const [playing, setPlaying] = useState(false);
|
|
const playingRef = useRef(false);
|
|
const [buffering, setBuffering] = useState(false);
|
|
const [loop, setLoop] = useState(true);
|
|
const loopRef = useRef(true);
|
|
loopRef.current = loop;
|
|
const [muted, setMuted] = useState(true);
|
|
const [hq, setHq] = useState(false);
|
|
const [spanVersion, bumpSpans] = useState(0);
|
|
/** The latest load into each slot; an older load's listeners stand down. */
|
|
const slotToken = useRef<[object | null, object | null]>([null, null]);
|
|
/** The clip on screen, by id, to stay on when the clip list changes. */
|
|
const shownId = useRef<string | null>(null);
|
|
/** Bumped on every goTo so a superseded load stops acting. */
|
|
const generation = useRef(0);
|
|
/** The slot a goTo is loading into, until its clip is on screen. */
|
|
const jumpingInto = useRef<Slot | null>(null);
|
|
/** How much earlier than its pre-roll frames the next clip must start. */
|
|
const startLatency = useRef(INITIAL_START_LATENCY_MS);
|
|
|
|
const srcFor = useCallback(
|
|
(clip: SequenceClip) => (hq && clip.hqUrl ? clip.hqUrl : clip.url),
|
|
[hq]
|
|
);
|
|
const srcForRef = useRef(srcFor);
|
|
srcForRef.current = srcFor;
|
|
|
|
const spanOf = useCallback(
|
|
(i: number) => spans.current.get(clips[i].id) ?? estimatedSpan(clips[i]),
|
|
[clips]
|
|
);
|
|
|
|
/**
|
|
* Load clip `i` into a slot and park it on `frameInMedia` (default: its cut
|
|
* in, less `preroll` frames). Resolves false if superseded or the media failed.
|
|
*/
|
|
const prepare = useCallback(
|
|
(slot: Slot, i: number, frameInMedia?: number, preroll = 0): Promise<boolean> => {
|
|
const video = videoRefs[slot].current;
|
|
const clip = clips[i];
|
|
if (!video || !clip) return Promise.resolve(false);
|
|
const url = srcForRef.current(clip);
|
|
const gen = generation.current;
|
|
const token = {};
|
|
slotToken.current[slot] = token;
|
|
|
|
return new Promise<boolean>((resolve) => {
|
|
const superseded = () => {
|
|
if (slotToken.current[slot] === token) return false;
|
|
cleanup();
|
|
resolve(false);
|
|
return true;
|
|
};
|
|
const cleanup = () => {
|
|
video.removeEventListener("loadedmetadata", onMeta);
|
|
video.removeEventListener("seeked", onSeeked);
|
|
video.removeEventListener("error", onError);
|
|
};
|
|
const onError = () => {
|
|
if (superseded()) return;
|
|
cleanup();
|
|
resolve(false);
|
|
};
|
|
const onSeeked = () => {
|
|
if (superseded()) return;
|
|
cleanup();
|
|
resolve(gen === generation.current);
|
|
};
|
|
const seek = () => {
|
|
const total = durationToFrameCount(video.duration, clip.fps);
|
|
const span = cutSpan(clip.handles, total);
|
|
if (span) spans.current.set(clip.id, span);
|
|
const cutIn = span?.start ?? 0;
|
|
const target = frameInMedia ?? Math.max(0, cutIn - preroll);
|
|
const entry = loaded.current[slot];
|
|
if (entry) {
|
|
entry.cutIn = cutIn;
|
|
entry.parkedAt = target;
|
|
}
|
|
video.addEventListener("seeked", onSeeked);
|
|
video.currentTime = frameToTime(target + SEEK_BIAS, clip.fps);
|
|
};
|
|
const onMeta = () => {
|
|
if (superseded()) return;
|
|
video.removeEventListener("loadedmetadata", onMeta);
|
|
bumpSpans((n) => n + 1);
|
|
seek();
|
|
};
|
|
|
|
video.addEventListener("error", onError);
|
|
const current = loaded.current[slot];
|
|
if (current && current.url === url && video.readyState >= 1) {
|
|
loaded.current[slot] = { index: i, url };
|
|
seek();
|
|
} else {
|
|
loaded.current[slot] = { index: i, url };
|
|
video.addEventListener("loadedmetadata", onMeta);
|
|
video.src = url;
|
|
video.load();
|
|
}
|
|
});
|
|
},
|
|
// videoRefs are stable refs
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[clips]
|
|
);
|
|
|
|
const nextIndex = useCallback(
|
|
(i: number) => (i + 1 < clips.length ? i + 1 : loopRef.current ? 0 : null),
|
|
[clips.length]
|
|
);
|
|
|
|
const preloadNext = useCallback(
|
|
(fromIndex: number) => {
|
|
const n = nextIndex(fromIndex);
|
|
if (n == null) return;
|
|
const standby = (1 - activeRef.current) as Slot;
|
|
// A jump is loading its clip there; it preloads the next one itself
|
|
// once it's on screen. Loading over it would leave the screen black.
|
|
if (jumpingInto.current === standby) return;
|
|
void prepare(standby, n, undefined, PREROLL_FRAMES);
|
|
},
|
|
[nextIndex, prepare]
|
|
);
|
|
|
|
const show = useCallback(
|
|
(slot: Slot, i: number) => {
|
|
activeRef.current = slot;
|
|
indexRef.current = i;
|
|
setActive(slot);
|
|
setIndex(i);
|
|
// Straight on the DOM, not via a re-render: a render later, the outgoing
|
|
// clip would already have painted a frame past its cut.
|
|
const other = videoRefs[1 - slot].current;
|
|
if (other) {
|
|
other.pause();
|
|
other.muted = true;
|
|
other.style.opacity = "0";
|
|
}
|
|
const video = videoRefs[slot].current;
|
|
if (video) {
|
|
video.style.opacity = "1";
|
|
video.muted = muted;
|
|
setFrame(timeToFrame(video.currentTime, clips[i].fps));
|
|
}
|
|
shownId.current = clips[i].id;
|
|
onClipChange?.(clips[i], i);
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[clips, muted, onClipChange]
|
|
);
|
|
|
|
/** Jump to clip `i` (at a media frame, default its cut in). */
|
|
const goTo = useCallback(
|
|
async (i: number, frameInMedia?: number, autoplay = playingRef.current) => {
|
|
if (!clips[i]) return;
|
|
generation.current += 1;
|
|
const gen = generation.current;
|
|
// Always via the hidden slot: the picture on screen stays put while the
|
|
// clip loads (loading into the visible slot flashed its slate), and when
|
|
// it already holds this clip — usually the next one — it's just a seek.
|
|
// A seek within the clip on screen stays where it is.
|
|
const onScreen = loaded.current[activeRef.current];
|
|
const slot =
|
|
onScreen?.index === i && onScreen.url === srcForRef.current(clips[i])
|
|
? activeRef.current
|
|
: ((1 - activeRef.current) as Slot);
|
|
videoRefs[0].current?.pause();
|
|
videoRefs[1].current?.pause();
|
|
setBuffering(true);
|
|
jumpingInto.current = slot;
|
|
const ok = await prepare(slot, i, frameInMedia);
|
|
if (gen !== generation.current) return;
|
|
jumpingInto.current = null;
|
|
setBuffering(false);
|
|
if (!ok) return;
|
|
show(slot, i);
|
|
if (autoplay) {
|
|
playingRef.current = true;
|
|
setPlaying(true);
|
|
videoRefs[slot].current?.play().catch(() => {});
|
|
}
|
|
preloadNext(i);
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[clips, prepare, show, preloadNext]
|
|
);
|
|
|
|
/** Put the (already playing) standby clip on screen. */
|
|
const cutTo = useCallback(
|
|
(slot: Slot, i: number) => {
|
|
videoRefs[1 - slot].current?.pause();
|
|
show(slot, i);
|
|
setTimeout(() => {
|
|
if (indexRef.current === i && activeRef.current === slot) preloadNext(i);
|
|
}, PRELOAD_DELAY_MS);
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[show, preloadNext]
|
|
);
|
|
|
|
/**
|
|
* Cut to the next clip now — the fallback when it wasn't pre-rolled (no
|
|
* requestVideoFrameCallback, or it wasn't loaded in time).
|
|
*/
|
|
const advance = useCallback(async () => {
|
|
const i = indexRef.current;
|
|
const n = nextIndex(i);
|
|
const slot = activeRef.current;
|
|
const current = videoRefs[slot].current;
|
|
if (n == null) {
|
|
// End of the playlist: hold the last frame of the cut.
|
|
current?.pause();
|
|
if (current) current.currentTime = frameToTime(spanOf(i).end - 1 + SEEK_BIAS, clips[i].fps);
|
|
playingRef.current = false;
|
|
setPlaying(false);
|
|
return;
|
|
}
|
|
const standby = (1 - slot) as Slot;
|
|
const ready = loaded.current[standby];
|
|
const standbyVideo = videoRefs[standby].current;
|
|
if (ready?.index === n && ready.cutIn != null && standbyVideo && standbyVideo.readyState >= 2) {
|
|
// Parked short of its cut for a pre-roll: bring it up to the cut in.
|
|
if (ready.parkedAt !== ready.cutIn || standbyVideo.seeking) {
|
|
const ok = await prepare(standby, n);
|
|
if (!ok || !playingRef.current || indexRef.current !== i) return;
|
|
}
|
|
standbyVideo.play().catch(() => {});
|
|
cutTo(standby, n);
|
|
} else {
|
|
// Next clip not ready (slow network): load it, then carry on.
|
|
void goTo(n, undefined, true);
|
|
}
|
|
}, [nextIndex, spanOf, clips, prepare, cutTo, goTo]);
|
|
|
|
// Playing: watch the active clip for its out-point, pre-roll the next one
|
|
// so it arrives on its cut in-point as the out-point passes, and keep the
|
|
// frame readout live.
|
|
//
|
|
// A frame is reported as it goes to the screen, so waiting to see the first
|
|
// handle frame would mean it had already been shown. Instead the active
|
|
// clip pauses on the last frame of its cut (holding it, never decoding the
|
|
// handle), and the cut happens when the next clip reports its cut in-point.
|
|
useEffect(() => {
|
|
if (!playing) return;
|
|
const video = videoRefs[active].current;
|
|
const clip = clips[index];
|
|
if (!video || !clip) return;
|
|
const end = spanOf(index).end;
|
|
const n = nextIndex(index);
|
|
const standbySlot = (1 - active) as Slot;
|
|
const standbyVideo = videoRefs[standbySlot].current;
|
|
const hasRvfc = "requestVideoFrameCallback" in HTMLVideoElement.prototype;
|
|
|
|
const armedGeneration = generation.current;
|
|
let disposed = false;
|
|
let held = false; // active clip paused on its last cut frame
|
|
let prerolling = false;
|
|
let didCut = false;
|
|
let handle = 0;
|
|
let standbyHandle = 0;
|
|
let cutTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let prerollTimer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const standbyReady = () => {
|
|
const l = loaded.current[standbySlot];
|
|
return n != null &&
|
|
hasRvfc &&
|
|
standbyVideo &&
|
|
l?.index === n &&
|
|
l.cutIn != null &&
|
|
l.parkedAt != null &&
|
|
l.parkedAt <= l.cutIn &&
|
|
standbyVideo.readyState >= 2 &&
|
|
!standbyVideo.seeking
|
|
? { l: l as Required<Loaded>, sv: standbyVideo }
|
|
: null;
|
|
};
|
|
|
|
/** `cutAt`: when the next clip's cut in-point should reach the screen. */
|
|
const startPreroll = (r: NonNullable<ReturnType<typeof standbyReady>>, cutAt: number) => {
|
|
if (disposed || prerolling || standbyReady() == null) return;
|
|
prerolling = true;
|
|
const onStandbyFrame = (_now: number, meta: VideoFrameCallbackMetadata) => {
|
|
if (disposed) return;
|
|
if (timeToFrame(meta.mediaTime, clips[r.l.index].fps) >= r.l.cutIn) {
|
|
// Calibrate: arrived early → start later next time, and vice versa.
|
|
const error = meta.expectedDisplayTime - cutAt;
|
|
if (Math.abs(error) < 250) {
|
|
startLatency.current = Math.min(
|
|
300,
|
|
Math.max(-100, startLatency.current + error / 2)
|
|
);
|
|
}
|
|
didCut = true;
|
|
cutTo(standbySlot, r.l.index);
|
|
return;
|
|
}
|
|
standbyHandle = r.sv.requestVideoFrameCallback(onStandbyFrame);
|
|
};
|
|
standbyHandle = r.sv.requestVideoFrameCallback(onStandbyFrame);
|
|
r.sv.play().catch(() => {});
|
|
};
|
|
|
|
const check = (mediaTime: number, shownAt: number) => {
|
|
const f = timeToFrame(mediaTime, clip.fps);
|
|
setFrame(f);
|
|
const frameMs = 1000 / clip.fps / (video.playbackRate || 1);
|
|
const now = performance.now();
|
|
// When the out-point would reach the screen.
|
|
const cutAt = shownAt + (end - f) * frameMs;
|
|
|
|
// Start the next clip `lead` before the cut — on a timer, as frame
|
|
// callbacks only come once a frame.
|
|
if (!prerolling && prerollTimer === undefined) {
|
|
const r = standbyReady();
|
|
if (r) {
|
|
const lead =
|
|
startLatency.current + (r.l.cutIn - r.l.parkedAt) * (1000 / clips[r.l.index].fps);
|
|
const startIn = cutAt - now - lead;
|
|
if (startIn < frameMs * 2) {
|
|
prerollTimer = setTimeout(() => startPreroll(r, cutAt), Math.max(0, startIn));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (f >= end - 1) {
|
|
held = true;
|
|
video.pause();
|
|
// Not pre-rolled: cut on time by hand. Pre-rolled: the next clip cuts
|
|
// itself in; this only catches it never arriving.
|
|
const wait =
|
|
Math.max(0, cutAt - now) + (prerolling || prerollTimer !== undefined ? 500 : 0);
|
|
cutTimer = setTimeout(() => {
|
|
if (!disposed && !didCut && playingRef.current) void advance();
|
|
}, wait);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
if (hasRvfc) {
|
|
const onFrame = (_now: number, meta: VideoFrameCallbackMetadata) => {
|
|
if (disposed || check(meta.mediaTime, meta.expectedDisplayTime)) return;
|
|
handle = video.requestVideoFrameCallback(onFrame);
|
|
};
|
|
handle = video.requestVideoFrameCallback(onFrame);
|
|
} else {
|
|
const tick = () => {
|
|
if (disposed || check(video.currentTime, performance.now())) return;
|
|
handle = requestAnimationFrame(tick);
|
|
};
|
|
handle = requestAnimationFrame(tick);
|
|
}
|
|
// Media that ends before the computed out-point (duration rounding).
|
|
const onEnded = () => {
|
|
if (!held && !prerolling) {
|
|
held = true;
|
|
void advance();
|
|
}
|
|
};
|
|
video.addEventListener("ended", onEnded);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
clearTimeout(cutTimer);
|
|
clearTimeout(prerollTimer);
|
|
video.removeEventListener("ended", onEnded);
|
|
if (hasRvfc) {
|
|
video.cancelVideoFrameCallback(handle);
|
|
standbyVideo?.cancelVideoFrameCallback(standbyHandle);
|
|
} else {
|
|
cancelAnimationFrame(handle);
|
|
}
|
|
// Stopped mid pre-roll (paused): park the next clip again — unless a
|
|
// jump has taken over, and the standby may now be the clip on screen.
|
|
if (
|
|
prerolling &&
|
|
!didCut &&
|
|
standbyVideo &&
|
|
n != null &&
|
|
generation.current === armedGeneration &&
|
|
activeRef.current !== standbySlot
|
|
) {
|
|
standbyVideo.pause();
|
|
void prepare(standbySlot, n, undefined, PREROLL_FRAMES);
|
|
}
|
|
};
|
|
// spans are read live; re-arm when the active clip changes
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [playing, active, index, clips, advance, cutTo]);
|
|
|
|
// A new clip list: stay on the clip on screen if it's still in the list
|
|
// (a shot checked or unchecked mid-review), else start from the first.
|
|
useEffect(() => {
|
|
if (!clips.length) return;
|
|
const video = videoRefs[activeRef.current].current;
|
|
const keep = shownId.current ? clips.findIndex((c) => c.id === shownId.current) : -1;
|
|
loaded.current = [null, null];
|
|
if (keep >= 0 && video) {
|
|
void goTo(keep, timeToFrame(video.currentTime, clips[keep].fps));
|
|
} else {
|
|
playingRef.current = false;
|
|
setPlaying(false);
|
|
void goTo(0, undefined, false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [clips]);
|
|
|
|
// Quality switch: reload the current clip at the same frame.
|
|
const lastHq = useRef(hq);
|
|
useEffect(() => {
|
|
if (lastHq.current === hq) return;
|
|
lastHq.current = hq;
|
|
const video = videoRefs[activeRef.current].current;
|
|
const clip = clips[indexRef.current];
|
|
if (!video || !clip) return;
|
|
const at = timeToFrame(video.currentTime, clip.fps);
|
|
loaded.current = [null, null];
|
|
void goTo(indexRef.current, at);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [hq]);
|
|
|
|
// Loop toggled: what comes after the last clip changed, so reload the
|
|
// standby. Not on mount — the first goTo is loading then.
|
|
const lastLoop = useRef(loop);
|
|
useEffect(() => {
|
|
if (lastLoop.current === loop) return;
|
|
lastLoop.current = loop;
|
|
if (clips.length) preloadNext(indexRef.current);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [loop]);
|
|
|
|
useEffect(() => {
|
|
const video = videoRefs[activeRef.current].current;
|
|
if (video) video.muted = muted;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [muted]);
|
|
|
|
// ── Transport ──────────────────────────────────────────────────────────────
|
|
const play = useCallback(() => {
|
|
const i = indexRef.current;
|
|
const video = videoRefs[activeRef.current].current;
|
|
const clip = clips[i];
|
|
if (!video || !clip) return;
|
|
const span = spanOf(i);
|
|
const f = timeToFrame(video.currentTime, clip.fps);
|
|
// Parked on the last frame of the cut (paused at the cut, or at the end
|
|
// of the playlist): carry on from the next clip, or start over.
|
|
if (f >= span.end - 1) {
|
|
if (nextIndex(i) == null) {
|
|
void goTo(0, undefined, true);
|
|
} else {
|
|
playingRef.current = true;
|
|
setPlaying(true);
|
|
void advance();
|
|
}
|
|
return;
|
|
}
|
|
if (f < span.start || f >= span.end) {
|
|
video.currentTime = frameToTime(span.start + SEEK_BIAS, clip.fps);
|
|
}
|
|
playingRef.current = true;
|
|
setPlaying(true);
|
|
video.play().catch(() => {});
|
|
}, [clips, spanOf, nextIndex, goTo, advance]);
|
|
|
|
const pause = useCallback(() => {
|
|
playingRef.current = false;
|
|
setPlaying(false);
|
|
videoRefs[activeRef.current].current?.pause();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const toggle = useCallback(() => (playingRef.current ? pause() : play()), [play, pause]);
|
|
|
|
/** Step within the cut; stepping past either end moves to the next/previous clip. */
|
|
const step = useCallback(
|
|
(delta: number) => {
|
|
pause();
|
|
const i = indexRef.current;
|
|
const video = videoRefs[activeRef.current].current;
|
|
const clip = clips[i];
|
|
if (!video || !clip) return;
|
|
const span = spanOf(i);
|
|
const f = timeToFrame(video.currentTime, clip.fps) + delta;
|
|
if (f >= span.end) {
|
|
if (i + 1 < clips.length) void goTo(i + 1, undefined, false);
|
|
return;
|
|
}
|
|
if (f < span.start) {
|
|
if (i > 0) void goTo(i - 1, spanOf(i - 1).end - 1, false);
|
|
return;
|
|
}
|
|
video.currentTime = frameToTime(f + SEEK_BIAS, clip.fps);
|
|
setFrame(f);
|
|
},
|
|
[clips, spanOf, goTo, pause]
|
|
);
|
|
|
|
const prevClip = useCallback(() => {
|
|
const i = indexRef.current;
|
|
const video = videoRefs[activeRef.current].current;
|
|
const clip = clips[i];
|
|
// Like a transport: first press returns to the head of this clip.
|
|
if (video && clip && timeToFrame(video.currentTime, clip.fps) > spanOf(i).start + 2) {
|
|
void goTo(i);
|
|
} else {
|
|
void goTo(Math.max(0, i - 1));
|
|
}
|
|
}, [clips, spanOf, goTo]);
|
|
|
|
const nextClip = useCallback(() => {
|
|
const n = nextIndex(indexRef.current);
|
|
if (n != null) void goTo(n);
|
|
}, [nextIndex, goTo]);
|
|
|
|
// Keyboard
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const t = e.target as HTMLElement | null;
|
|
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
|
switch (e.key) {
|
|
case " ":
|
|
case "k":
|
|
case "K":
|
|
e.preventDefault();
|
|
toggle();
|
|
break;
|
|
case "ArrowRight":
|
|
e.preventDefault();
|
|
step(1);
|
|
break;
|
|
case "ArrowLeft":
|
|
e.preventDefault();
|
|
step(-1);
|
|
break;
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
nextClip();
|
|
break;
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
prevClip();
|
|
break;
|
|
case "Home":
|
|
e.preventDefault();
|
|
void goTo(0);
|
|
break;
|
|
case "m":
|
|
case "M":
|
|
setMuted((m) => !m);
|
|
break;
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [toggle, step, nextClip, prevClip, goTo]);
|
|
|
|
// ── Timeline ───────────────────────────────────────────────────────────────
|
|
const segments = useMemo(() => {
|
|
let offset = 0;
|
|
return clips.map((clip, i) => {
|
|
const span = spanOf(i);
|
|
const length = span.end - span.start;
|
|
const seg = { clip, span, offset, length };
|
|
offset += length;
|
|
return seg;
|
|
});
|
|
// spans refine as clips load (bumpSpans)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [clips, spanOf, spanVersion]);
|
|
const totalLength = segments.reduce((sum, s) => sum + s.length, 0) || 1;
|
|
const current = segments[index];
|
|
const posInClip = current ? Math.min(Math.max(frame - current.span.start, 0), current.length - 1) : 0;
|
|
const position = current ? current.offset + posInClip : 0;
|
|
const fps = clips[index]?.fps ?? 24;
|
|
|
|
const seekTimeline = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const target = ((e.clientX - rect.left) / rect.width) * totalLength;
|
|
const seg =
|
|
segments.find((s) => target < s.offset + s.length) ?? segments[segments.length - 1];
|
|
if (!seg) return;
|
|
const i = segments.indexOf(seg);
|
|
const f = seg.span.start + Math.min(seg.length - 1, Math.floor(target - seg.offset));
|
|
if (i === indexRef.current) {
|
|
const video = videoRefs[activeRef.current].current;
|
|
if (video) {
|
|
video.currentTime = frameToTime(f + SEEK_BIAS, seg.clip.fps);
|
|
setFrame(f);
|
|
}
|
|
} else {
|
|
void goTo(i, f);
|
|
}
|
|
};
|
|
|
|
const anyHq = clips.some((c) => c.hqUrl);
|
|
|
|
if (!clips.length) return null;
|
|
|
|
return (
|
|
<div className="flex flex-col flex-1 min-h-0 bg-black">
|
|
{/* Stage */}
|
|
<div className="relative flex-1 min-h-0" onClick={toggle}>
|
|
{[0, 1].map((slot) => (
|
|
<video
|
|
key={slot}
|
|
ref={videoRefs[slot]}
|
|
playsInline
|
|
muted
|
|
preload="auto"
|
|
// Initial stacking only; show() swaps opacity on the DOM from then on.
|
|
style={{ opacity: slot === 0 ? 1 : 0 }}
|
|
className="absolute inset-0 w-full h-full object-contain"
|
|
/>
|
|
))}
|
|
{current && (
|
|
<div className="absolute top-3 left-3 pointer-events-none flex items-center gap-2 rounded bg-black/60 px-2 py-1">
|
|
<span className="font-mono text-xs font-semibold text-white">{current.clip.label}</span>
|
|
{current.clip.sublabel && (
|
|
<span className="text-[10px] text-zinc-400">{current.clip.sublabel}</span>
|
|
)}
|
|
<span className="text-[10px] text-zinc-500">
|
|
{index + 1}/{clips.length}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{buffering && (
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<Loader2 className="h-6 w-6 animate-spin text-zinc-400" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Timeline: one segment per clip, sized by its cut length */}
|
|
<div className="px-3 pt-2 shrink-0">
|
|
<div
|
|
className="relative h-7 flex rounded overflow-hidden cursor-pointer bg-zinc-900 select-none"
|
|
onClick={seekTimeline}
|
|
>
|
|
{segments.map((s, i) => (
|
|
<div
|
|
key={s.clip.id}
|
|
style={{ width: `${(s.length / totalLength) * 100}%` }}
|
|
className={cn(
|
|
"h-full border-r border-black/70 px-1 flex items-center overflow-hidden",
|
|
i === index ? "bg-blue-500/30" : i % 2 ? "bg-zinc-800" : "bg-zinc-800/60"
|
|
)}
|
|
title={`${s.clip.label} · ${s.length} fr`}
|
|
>
|
|
<span className="text-[10px] font-mono text-zinc-300 truncate">{s.clip.label}</span>
|
|
</div>
|
|
))}
|
|
<div
|
|
className="absolute top-0 bottom-0 w-px bg-white pointer-events-none"
|
|
style={{ left: `${(position / totalLength) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Transport */}
|
|
<div className="flex items-center gap-1 px-3 py-2 shrink-0 text-zinc-300">
|
|
<TransportButton onClick={prevClip} title="Previous shot (↑)">
|
|
<ChevronFirst className="h-4 w-4" />
|
|
</TransportButton>
|
|
<TransportButton onClick={toggle} title="Play / pause (Space)">
|
|
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
|
</TransportButton>
|
|
<TransportButton onClick={nextClip} title="Next shot (↓)">
|
|
<ChevronLast className="h-4 w-4" />
|
|
</TransportButton>
|
|
|
|
<span className="ml-2 font-mono text-xs tabular-nums text-zinc-400">
|
|
{frameToTimecode(position, fps)}
|
|
<span className="text-zinc-600"> / {frameToTimecode(totalLength, fps)}</span>
|
|
</span>
|
|
<span className="ml-3 font-mono text-[10px] text-zinc-600 tabular-nums">
|
|
shot fr {posInClip + 1}/{current?.length ?? 0}
|
|
</span>
|
|
|
|
<div className="ml-auto flex items-center gap-1">
|
|
{anyHq && (
|
|
<button
|
|
onClick={() => setHq((v) => !v)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
className={cn(
|
|
"px-2 h-7 rounded text-[10px] font-semibold uppercase tracking-wider border",
|
|
hq
|
|
? "border-blue-500/40 text-blue-300 bg-blue-500/10"
|
|
: "border-zinc-700 text-zinc-500 hover:text-zinc-300"
|
|
)}
|
|
title="High quality where available"
|
|
>
|
|
HQ
|
|
</button>
|
|
)}
|
|
<TransportButton
|
|
onClick={() => setLoop((v) => !v)}
|
|
title="Loop playlist"
|
|
active={loop}
|
|
>
|
|
<Repeat className="h-4 w-4" />
|
|
</TransportButton>
|
|
<TransportButton onClick={() => setMuted((m) => !m)} title="Mute (M)">
|
|
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
|
</TransportButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TransportButton({
|
|
onClick,
|
|
title,
|
|
active,
|
|
children,
|
|
}: {
|
|
onClick: () => void;
|
|
title: string;
|
|
active?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
// Keep focus off the button so Space stays play/pause.
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
title={title}
|
|
className={cn(
|
|
"h-8 w-8 flex items-center justify-center rounded hover:bg-zinc-800 transition-colors",
|
|
active ? "text-blue-400" : "text-zinc-300"
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|