Files
vfxreview/components/player/SequencePlayer.tsx
T
twotalesanimation 326d8cbefa
Deploy / deploy (push) Failing after 1m25s
fix(playlist): black screen in production builds
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>
2026-09-26 11:54:02 +02:00

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>
);
}