diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index b8d9cae60..1a1a68983 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -198,6 +198,26 @@ describe("HyperframesPlayer parent-frame media", () => { expect(mockAudio.pause).toHaveBeenCalled(); }); + it("seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)", () => { + // Regression: previously `seek()` only called `seekAll()`, leaving the + // proxy playing. With the timeline frozen at the new seek target, the + // parent's `mirrorTime` drift-correction would yank `currentTime` back + // every ~80ms of accumulated drift, producing an audible audio stutter + // loop while the video frame stayed frozen. `seek()` must be symmetric + // with `pause()` for the parent-owned audio path. + player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); + document.body.appendChild(player); + + player._promoteToParentProxy?.(); + player.play(); + expect(mockAudio.play).toHaveBeenCalled(); + mockAudio.pause.mockClear(); + + player.seek(12.5); + expect(mockAudio.pause).toHaveBeenCalled(); + expect(mockAudio.currentTime).toBe(12.5); + }); + it("promotion is idempotent", () => { player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index ecaf7a363..902f8b423 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -264,7 +264,15 @@ class HyperframesPlayer extends HTMLElement { this._directTimelineClock.stop(); this._stopParentTickClock(); this._currentTime = timeInSeconds; - if (this._media.audioOwner === "parent") this._media.seekAll(timeInSeconds); + if (this._media.audioOwner === "parent") { + // Pause BEFORE seek: leaving the proxy playing turns the next + // `mirrorTime` drift-correction tick into a perpetual seek→play→drift→seek + // stutter loop, where ~80ms of audio plays past the (now frozen) timeline, + // then mirrorTime yanks `currentTime` back to match it. Symmetric with + // `pause()` below. + this._media.pauseAll(); + this._media.seekAll(timeInSeconds); + } this._paused = true; this.controlsApi?.updatePlaying(false); this.controlsApi?.updateTime(this._currentTime, this._duration);