fix(playlist): black screen in production builds
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>
This commit is contained in:
twotalesanimation
2026-09-26 11:54:02 +02:00
parent b596c97faf
commit 326d8cbefa
+23 -3
View File
@@ -116,6 +116,8 @@ export function SequencePlayer({ clips, onClipChange }: SequencePlayerProps) {
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);
@@ -216,6 +218,9 @@ export function SequencePlayer({ clips, onClipChange }: SequencePlayerProps) {
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]
@@ -266,8 +271,10 @@ export function SequencePlayer({ clips, onClipChange }: SequencePlayerProps) {
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);
@@ -348,6 +355,7 @@ export function SequencePlayer({ clips, onClipChange }: SequencePlayerProps) {
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;
@@ -467,8 +475,16 @@ export function SequencePlayer({ clips, onClipChange }: SequencePlayerProps) {
} else {
cancelAnimationFrame(handle);
}
// Stopped mid pre-roll (paused, skipped): park the next clip again.
if (prerolling && !didCut && standbyVideo && n != null) {
// 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);
}
@@ -508,8 +524,12 @@ export function SequencePlayer({ clips, onClipChange }: SequencePlayerProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hq]);
// Reloading the standby when loop is toggled at the end of the list.
// 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]);