diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 7ad7a87e4d..bc4da22925 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -925,7 +925,8 @@ describe("HyperframesPlayer seek() sync path", () => { player.seek(2); expect(timeline.seek).toHaveBeenCalledTimes(1); - expect(timeline.seek).toHaveBeenCalledWith(2); + // suppressEvents=false so onUpdate fires (imperative-visibility compositions repaint). + expect(timeline.seek).toHaveBeenCalledWith(2, false); expect(post).not.toHaveBeenCalled(); }); @@ -964,7 +965,7 @@ describe("HyperframesPlayer seek() sync path", () => { pause.mockClear(); player.seek(2); - expect(timeline.seek).toHaveBeenCalledWith(2); + expect(timeline.seek).toHaveBeenCalledWith(2, false); expect(pause).toHaveBeenCalledTimes(1); expect(post).not.toHaveBeenCalled(); }); diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 3f10620be4..a13d866ddf 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -531,7 +531,10 @@ class HyperframesPlayer extends HTMLElement { // GSAP seek() preserves play state; player seek() contract lands paused. private _tryDirectTimelineSeek(t: number): boolean { return this._withDirectTimeline((tl) => { - tl.seek(t); + // suppressEvents=false: fire the timeline's onUpdate so compositions that + // drive scene visibility imperatively (via the root timeline's onUpdate, + // e.g. slideshow decks) repaint on a paused seek — not only while playing. + tl.seek(t, false); tl.pause(); }); } diff --git a/packages/player/src/slideshow/SlideshowController.test.ts b/packages/player/src/slideshow/SlideshowController.test.ts index adca44771b..e4be7de0ae 100644 --- a/packages/player/src/slideshow/SlideshowController.test.ts +++ b/packages/player/src/slideshow/SlideshowController.test.ts @@ -41,15 +41,14 @@ const SHOW: ResolvedSlideshow = { }; /** - * Factory: controller on SHOW, advanced to fragmentIndex=1 via playback - * (emit 2 → frag 0, next(), emit 4 → frag 1). Used across Fix 8b + backToMain tests. + * Factory: controller on SHOW, advanced to fragmentIndex=1. Construction enters + * slide a at fragmentIndex 0 (its first fragment); one next() reveals fragment 1. + * Navigation is synchronous (seek-driven) — no playback emit needed. */ function showAtFrag1() { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - p.emit(2.2); // fragmentIndex=0 - c.next(); // target=4 - p.emit(4.2); // fragmentIndex=1 + c.next(); // fragmentIndex 0 → 1 return { p, c }; } @@ -66,28 +65,28 @@ function showAtSlide1InDeep() { } describe("SlideshowController linear nav", () => { - it("enters the first slide on construction: jumps to the first hold (no auto-play)", () => { + it("enters the first slide on construction: seeks to the first fragment (no auto-play)", () => { const p = fakePlayer(); - new SlideshowController(p, SHOW); - // No auto-play: seeks (jumps) to the first hold, fragments[0]=2; - // playTo then plays a brief forward render-nudge and pauses there. + const c = new SlideshowController(p, SHOW); + // Synchronous seek-only hold: jump to fragments[0]=2, fragmentIndex 0, never play. expect(p.seek).toHaveBeenCalledWith(2); - expect(p.play).toHaveBeenCalled(); + expect(p.play).not.toHaveBeenCalled(); + expect(c.position.slideIndex).toBe(0); + expect(c.position.fragmentIndex).toBe(0); }); - it("holds (pauses) at slide end when timeupdate reaches it", () => { + it("never auto-plays — a single seek both repaints and holds", () => { const p = fakePlayer(); new SlideshowController(p, SHOW); - p.emit(2); // first fragment — handled separately; still inside slide - p.emit(5); // reached end - expect(p.pause).toHaveBeenCalled(); + // Determinism: navigation is a pure seek; the player is never put into a + // playing state that could run on into the next fragment/scene. + expect(p.play).not.toHaveBeenCalled(); }); it("next stops at the first fragment, not the next slide", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - p.emit(2.2); // play reaches fragment 0, controller pauses - expect(p.pause).toHaveBeenCalled(); + // Construction already lands on fragment 0 of slide a. expect(c.position.slideIndex).toBe(0); expect(c.position.fragmentIndex).toBe(0); }); @@ -95,10 +94,8 @@ describe("SlideshowController linear nav", () => { it("next past the last fragment advances to the next slide immediately", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - c.next(); // -> fragment 1 target (2) - p.emit(2.2); - c.next(); // -> fragment 2 target (4) - p.emit(4.2); + // construction → fragment 0; next → fragment 1; next → slide b (no fragments) + c.next(); // fragmentIndex 0 → 1 (seek 4) c.next(); // no more fragments — advance to slide b immediately expect(c.position.slideIndex).toBe(1); expect(p.seek).toHaveBeenLastCalledWith(7.5); // slide b midpoint @@ -144,16 +141,13 @@ describe("SlideshowController linear nav", () => { expect(c.position.slideIndex).toBe(0); }); - it("auto-pauses at a fragment, then next advances to the FOLLOWING fragment (not the end)", () => { + it("at a fragment, next advances to the FOLLOWING fragment (not the end)", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - p.emit(2.2); // auto-pause at fragments[0]=2 - expect(c.position.fragmentIndex).toBe(0); - p.pause.mockClear(); // clear the pause from the auto-stop above + expect(c.position.fragmentIndex).toBe(0); // construction → fragment 0 c.next(); // should target fragments[1]=4, NOT slide.end=5 - p.emit(4.2); - expect(p.pause).toHaveBeenCalled(); // must pause at 4, not skip to 5 expect(c.position.fragmentIndex).toBe(1); + expect(p.seek).toHaveBeenLastCalledWith(4); }); }); @@ -218,31 +212,24 @@ describe("SlideshowController branching", () => { }); }); -describe("SlideshowController Fix 8a — fragmentIndex advances via onTime not next()", () => { - it("next() does NOT pre-increment fragmentIndex; onTime advances it when hold fires", () => { +describe("SlideshowController — fragmentIndex advances synchronously on next()", () => { + it("construction lands on fragment 0; next() reveals fragment 1 immediately", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - // fragmentIndex starts at -1 (enterSlide sets it) - expect(c.position.fragmentIndex).toBe(-1); - // Call next() — should NOT pre-increment fragmentIndex + // Seek-only model: entering a fragmented slide shows its first fragment. + expect(c.position.fragmentIndex).toBe(0); + expect(p.seek).toHaveBeenLastCalledWith(2); // fragments[0] c.next(); - expect(c.position.fragmentIndex).toBe(-1); // still -1 until onTime fires - // Simulate playback reaching the hold point (fragments[0]=2) - p.emit(2.2); - expect(c.position.fragmentIndex).toBe(0); // onTime advanced it + expect(c.position.fragmentIndex).toBe(1); // synchronous, no played tick + expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1] }); - it("next() after auto-pause targets the FOLLOWING fragment without pre-increment (regression)", () => { + it("next() targets the FOLLOWING fragment (not slide end) while fragments remain", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - p.emit(2.2); // auto-pause at fragments[0]=2; fragmentIndex=0 - expect(c.position.fragmentIndex).toBe(0); - p.pause.mockClear(); - c.next(); // should target fragments[1]=4 — fragmentIndex stays 0 until emit - expect(c.position.fragmentIndex).toBe(0); // NOT yet 1 - p.emit(4.2); - expect(p.pause).toHaveBeenCalled(); - expect(c.position.fragmentIndex).toBe(1); // onTime advanced it + c.next(); // fragment 0 → 1 (fragments[1]=4, NOT slide.end=5) + expect(c.position.fragmentIndex).toBe(1); + expect(p.seek).toHaveBeenLastCalledWith(4); }); }); @@ -261,15 +248,14 @@ describe("SlideshowController Fix 8b — back() restores parent fragmentIndex", expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1] = 4 }); - it("back() when parent fragmentIndex=-1 seeks to slide start", () => { + it("resuming a fragmented slide at fragmentIndex -1 seeks to slide start", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - // Enter branch immediately (slide a HAS fragments; fragmentIndex is still -1, - // i.e. before the first reveal → resume to slide.start). - c.enterBranch("deep"); - c.back(); + // fragmentIndex -1 on a fragmented slide = before the first reveal. This state + // is reachable via syncTo (audience mirror); resume should seek to slide.start. + c.syncTo("main", 0, -1); expect(c.position.fragmentIndex).toBe(-1); - expect(p.seek).toHaveBeenLastCalledWith(0); + expect(p.seek).toHaveBeenLastCalledWith(0); // slide a start }); it("back() to a NO-fragment parent slide resumes at its midpoint, not frame 0", () => { @@ -372,16 +358,17 @@ describe("SlideshowController Fix #backToMain — restores fragment position lik expect(p.seek).toHaveBeenLastCalledWith(4); }); - it("backToMain when root fragmentIndex=-1 seeks to slide start", () => { + it("backToMain restores the root fragment the branch was entered from", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - // Enter branch immediately (root fragmentIndex is still -1) + // Construction lands on slide a fragment 0; enter a branch, then return. c.enterBranch("deep"); c.backToMain(); - expect(c.position.fragmentIndex).toBe(-1); - expect(p.seek).toHaveBeenLastCalledWith(0); // slide start + expect(c.position.slideIndex).toBe(0); + expect(c.position.fragmentIndex).toBe(0); + expect(p.seek).toHaveBeenLastCalledWith(2); // fragments[0] }); it("backToMain with multiple nested branches restores root slide position", () => { @@ -593,13 +580,13 @@ describe("SlideshowController next() — reveals remaining fragments at slide en it("reveals the next fragment even when currentTime is already at slide end", () => { const p = fakePlayer(); const c = new SlideshowController(p, SHOW); - // Simulate a static jump to slide end without having stepped through fragments. - p.currentTime = 5; // slide a end, fragmentIndex still -1 - c.next(); - // Should target the first fragment (2) rather than advancing to slide b. + // Static jump to slide end; pending fragments should still be revealed in order + // (the playhead position doesn't gate fragment stepping). + p.currentTime = 5; // slide a end + c.next(); // fragment 0 → 1, stays on slide a expect(c.position.slideIndex).toBe(0); - expect(p.play).toHaveBeenCalled(); - expect(p.seek).not.toHaveBeenLastCalledWith(5); // not advanced to slide b start + expect(c.position.fragmentIndex).toBe(1); + expect(p.seek).toHaveBeenLastCalledWith(4); // fragments[1], not slide b }); }); @@ -614,12 +601,10 @@ describe("SlideshowController syncTo", () => { expect(c.position.sequenceId).toBe("deep"); expect(c.position.slideIndex).toBe(0); // Slide c has no fragments, so resumeSlide lands at its midpoint (restFrame) — - // the same visible-at-rest position enterSlide uses — not slide start. It then - // plays a render-nudge so the composition repaints; onTime pauses at the hold. + // the same visible-at-rest position enterSlide uses — not slide start. A single + // seek both repaints and holds (no sustained playback). expect(p.seek).toHaveBeenLastCalledWith(11.5); // slide c midpoint (10 + 3*0.5) - expect(p.play).toHaveBeenCalled(); - p.emit(50); // player passes the render-nudge hold - expect(p.pause).toHaveBeenCalled(); + expect(p.play).not.toHaveBeenCalled(); }); it("syncs a main-line slide+fragment position without animating", () => { diff --git a/packages/player/src/slideshow/SlideshowController.ts b/packages/player/src/slideshow/SlideshowController.ts index 2504278617..69bc16a1c6 100644 --- a/packages/player/src/slideshow/SlideshowController.ts +++ b/packages/player/src/slideshow/SlideshowController.ts @@ -15,33 +15,21 @@ interface StackFrame { } const MAIN = "main"; -const EPS = 0.001; -// Seconds to play past a restored/mirrored position so the composition repaints -// (a bare paused seek doesn't re-render some compositions; pausing on the first -// timeupdate fires before a paint). -const RENDER_NUDGE = 0.2; export class SlideshowController { private stack: StackFrame[] = [{ sequenceId: MAIN, slideIndex: 0, fragmentIndex: -1 }]; - private holdAt: number | null = null; - // The logical hold (a fragment time / slide point). playTo() plays a short way - // PAST it (to holdAt) so the composition repaints; holdTarget is what onTime - // matches against fragments to advance fragmentIndex. - private holdTarget: number | null = null; private changeCbs = new Set<() => void>(); - private unsub: () => void; constructor( private player: PlayerPort, private show: ResolvedSlideshow, ) { - this.unsub = player.onTimeUpdate((t) => this.onTime(t)); this.enterSlide(0); } // fallow-ignore-next-line unused-class-member dispose(): void { - this.unsub(); + // No subscriptions to tear down — navigation is seek-driven (see playTo). } private slidesOf(sequenceId: string): ResolvedSlide[] { @@ -105,17 +93,22 @@ export class SlideshowController { private enterSlide(index: number): void { this.frame.slideIndex = index; - this.frame.fragmentIndex = -1; - this.holdAt = null; const slide = this.currentSlide; - if (!slide) return; + if (!slide) { + this.frame.fragmentIndex = -1; + return; + } // Jump to the slide's first hold and stay there (no auto-progress). With - // fragments that's the first fragment; without, a settled frame INSIDE the - // slide (its midpoint) — NOT slide.end, which is the boundary where the next - // scene begins (else slide 1 would render slide 2's content). - const firstHold = - slide.fragments.length > 0 ? (slide.fragments[0] ?? slide.end) : this.restFrame(slide); - this.playTo(firstHold); + // fragments that's the first fragment (fragmentIndex 0); without, a settled + // frame INSIDE the slide (its midpoint) — NOT slide.end, which is the boundary + // where the next scene begins (else slide 1 would render slide 2's content). + if (slide.fragments.length > 0) { + this.frame.fragmentIndex = 0; + this.playTo(slide.fragments[0] ?? slide.end); + } else { + this.frame.fragmentIndex = -1; + this.playTo(this.restFrame(slide)); + } this.emitChange(); } @@ -145,51 +138,25 @@ export class SlideshowController { : slide.fragments.length > 0 ? slide.start : this.restFrame(slide); - this.holdAt = null; this.playTo(seekTime); this.emitChange(); } - private nextStop(slide: ResolvedSlide, fragmentIndex: number): number { - const next = slide.fragments[fragmentIndex + 1]; - return next ?? slide.end; - } - /** - * Jump to hold time `t` and pause there — NO sustained playback, so slides - * never auto-progress. Seeks just before `t` and plays a short render-nudge - * ending at `t`: a bare paused seek doesn't repaint some compositions, and - * pausing on the first timeupdate fires before a paint. onTime() pauses at `t` - * and advances fragmentIndex when `t` is a fragment boundary. + * Jump to hold time `t` and hold there — a pure, synchronous seek with NO + * sustained playback, so a slide can never auto-progress. + * + * `player.seek(t)` drives the composition's GSAP timeline directly (the player + * reaches the same-origin iframe's `__timelines`), and GSAP `.seek()` renders + * that frame synchronously AND leaves the timeline paused. So one seek both + * repaints and holds — deterministically, in every window including a + * backgrounded one. (The previous play-a-frame-then-pause-on-a-timeupdate + * "render nudge" left an unfocused audience window playing while it waited for + * a throttled tick to pause it — that was the auto-progress / one-side-frozen + * flakiness. fragmentIndex is now set by the caller, not on a played tick.) */ private playTo(t: number): void { - // Seek to the EXACT target so the first repainted frame is the correct one — - // seeking BEFORE it (as a backward render-nudge) flashes a pre-target frame - // / the previous scene. Then play a short way PAST it so the composition - // actually repaints (a bare paused seek doesn't), and onTime() pauses there. - const slide = this.currentSlide; - this.holdTarget = t; - this.holdAt = slide ? Math.min(t + RENDER_NUDGE, slide.end) : t + RENDER_NUDGE; this.player.seek(t); - this.player.play(); - } - - private onTime(tt: number): void { - if (this.holdAt !== null && tt >= this.holdAt - EPS) { - const target = this.holdTarget; - this.holdAt = null; - this.holdTarget = null; - // Advance fragmentIndex if the logical target is a fragment boundary. - const slide = this.currentSlide; - if (slide && target !== null) { - const fragIdx = slide.fragments.indexOf(target); - if (fragIdx !== -1) { - this.frame.fragmentIndex = fragIdx; - this.emitChange(); - } - } - this.player.pause(); - } } next(): void { @@ -197,9 +164,10 @@ export class SlideshowController { if (!slide) return; const hasMoreFragments = this.frame.fragmentIndex + 1 < slide.fragments.length; if (hasMoreFragments) { - // Reveal the next fragment. onTime() advances fragmentIndex at the hold. - const nextTarget = this.nextStop(slide, this.frame.fragmentIndex); - this.playTo(nextTarget); + // Reveal the next fragment — advance the index and seek to its hold time. + this.frame.fragmentIndex += 1; + const target = slide.fragments[this.frame.fragmentIndex] ?? slide.end; + this.playTo(target); this.emitChange(); return; } diff --git a/packages/player/src/timeline-adapters.ts b/packages/player/src/timeline-adapters.ts index 5d19eaab7c..e38a98a94a 100644 --- a/packages/player/src/timeline-adapters.ts +++ b/packages/player/src/timeline-adapters.ts @@ -21,7 +21,9 @@ export interface RuntimeDurationAdapter { export interface DirectTimelineAdapter { duration: () => number; time: () => number; - seek: (timeInSeconds: number) => unknown; + // suppressEvents mirrors GSAP's timeline.seek(position, suppressEvents); pass + // false to fire onUpdate (so imperative-visibility compositions repaint on seek). + seek: (timeInSeconds: number, suppressEvents?: boolean) => unknown; play: () => unknown; pause: () => unknown; /** Optional: set playback rate (e.g. GSAP's timeScale). Called when the player's playbackRate changes. */