Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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();
});
Expand Down
5 changes: 4 additions & 1 deletion packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
Expand Down
115 changes: 50 additions & 65 deletions packages/player/src/slideshow/SlideshowController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand All @@ -66,39 +65,37 @@ 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);
});

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
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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);
});
});

Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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
});
});

Expand All @@ -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", () => {
Expand Down
92 changes: 30 additions & 62 deletions packages/player/src/slideshow/SlideshowController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -145,61 +138,36 @@ 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 {
const slide = this.currentSlide;
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;
}
Expand Down
Loading
Loading