diff --git a/packages/player/src/controls.ts b/packages/player/src/controls.ts index 7b799422b..4889a2a07 100644 --- a/packages/player/src/controls.ts +++ b/packages/player/src/controls.ts @@ -326,9 +326,11 @@ export function createControls( return { updateTime(current: number, duration: number) { - const pct = duration > 0 ? (current / duration) * 100 : 0; + // Defensive: source should already clamp, but guard here so the UI never overflows. + const clampedCurrent = duration > 0 ? Math.min(current, duration) : current; + const pct = duration > 0 ? (clampedCurrent / duration) * 100 : 0; progress.style.width = `${pct}%`; - time.textContent = `${formatTime(current)} / ${formatTime(duration)}`; + time.textContent = `${formatTime(clampedCurrent)} / ${formatTime(duration)}`; }, updatePlaying(playing: boolean) { isPlaying = playing; diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 97131c58f..98b30762e 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1028,6 +1028,7 @@ describe("HyperframesPlayer loop end-state handling", () => { seek: (timeInSeconds: number) => void; loop: boolean; _duration: number; + _currentTime: number; _paused: boolean; _onMessage: (event: MessageEvent) => void; }; @@ -1152,6 +1153,30 @@ describe("HyperframesPlayer loop end-state handling", () => { expect(seek).not.toHaveBeenCalled(); expect(player._paused).toBe(false); }); + + it("clamps _currentTime to _duration when a state message reports a frame past the end", () => { + // Regression test: the postMessage state path previously set _currentTime + // without clamping, while the direct timeline path already clamped. A frame + // count slightly past the end (common on final-frame messages) would set + // _currentTime > _duration, causing the progress bar to overflow the + // scrubber track and the time display to show e.g. "0:05 / 0:04". + player._duration = 4; // 4s = 120 frames at 30fps + player._paused = false; + + player._onMessage( + new MessageEvent("message", { + source: frameWindow, + data: { + source: "hf-preview", + type: "state", + frame: 150, // 5s — past the 4s duration + isPlaying: false, + }, + }), + ); + + expect(player._currentTime).toBe(4); + }); }); describe("HyperframesPlayer srcdoc attribute", () => { diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 2980f9fab..c9c7ffdad 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -1030,7 +1030,8 @@ class HyperframesPlayer extends HTMLElement { } if (data.type === "state") { - this._currentTime = (data.frame ?? 0) / DEFAULT_FPS; + const rawTime = (data.frame ?? 0) / DEFAULT_FPS; + this._currentTime = this._duration > 0 ? Math.min(rawTime, this._duration) : rawTime; const wasPlaying = !this._paused; const nextPaused = !data.isPlaying; const completedPlayback = diff --git a/packages/player/src/styles.ts b/packages/player/src/styles.ts index 4d99c1e76..0b08ae37c 100644 --- a/packages/player/src/styles.ts +++ b/packages/player/src/styles.ts @@ -247,11 +247,13 @@ export const PLAYER_STYLES = /* css */ ` .hfp-scrubber { flex: 1; + min-width: 0; height: var(--hfp-scrubber-height, 4px); background: var(--hfp-scrubber-bg, rgba(255, 255, 255, 0.3)); border-radius: var(--hfp-scrubber-radius, 2px); cursor: pointer; position: relative; + overflow: hidden; } .hfp-scrubber:hover { @@ -264,7 +266,6 @@ export const PLAYER_STYLES = /* css */ ` left: 0; height: 100%; background: var(--hfp-accent, #fff); - border-radius: var(--hfp-scrubber-radius, 2px); pointer-events: none; } @@ -401,6 +402,7 @@ export const PLAYER_STYLES = /* css */ ` border-radius: var(--hfp-scrubber-radius, 2px); cursor: pointer; position: relative; + overflow: hidden; margin-left: 4px; margin-right: 4px; } @@ -411,7 +413,6 @@ export const PLAYER_STYLES = /* css */ ` left: 0; height: 100%; background: var(--hfp-accent, #fff); - border-radius: var(--hfp-scrubber-radius, 2px); pointer-events: none; } `;