fix(player): clamp scrubber progress when postMessage frame exceeds duration (#700)

The postMessage state path set `_currentTime` without clamping, while the
direct timeline path already used `Math.min(currentTime, _duration)`. A
final-frame state message with a frame count slightly past the end would
set `_currentTime > _duration`, causing the progress bar (position:
absolute, no overflow guard) to bleed out of the scrubber track and
visually cover the volume button, and the time display to show values
like "0:05 / 0:04".

- Clamp `_currentTime` in `_onMessage` to match the direct timeline path
- Clamp defensively in `updateTime` so the display layer never overflows
- Add `overflow: hidden` + `min-width: 0` to `.hfp-scrubber` as a CSS
  safety net; remove now-redundant `border-radius` from `.hfp-progress`
  (parent `overflow: hidden` handles clipping to the rounded shape)
- Apply the same `overflow: hidden` fix to `.hfp-volume-slider` for
  consistency; remove redundant `border-radius` from `.hfp-volume-fill`
- Add regression test covering the postMessage over-duration case

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
terencecho
2026-05-09 22:23:19 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 811f309ea5
commit dd375e2784
4 changed files with 34 additions and 5 deletions
+4 -2
View File
@@ -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;
@@ -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", () => {
+2 -1
View File
@@ -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 =
+3 -2
View File
@@ -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;
}
`;