Skip to content

fix(player): keep the video playing when you jump to another moment - #2

Draft
robinebers wants to merge 13 commits into
mainfrom
fix/player-seek-keeps-playing
Draft

robinebers wants to merge 13 commits into
mainfrom
fix/player-seek-keeps-playing

Conversation

@robinebers

Copy link
Copy Markdown
Owner

Why

When you watch a video in the HyperFrames player and click somewhere on the progress bar to jump ahead, the video stops. You land on the new moment, frozen, and have to press play again. Dragging along the bar does the same. Every other video player, from YouTube to the browser's own, keeps playing from the new spot, so this feels broken. Apps that draw their own play button also fall out of sync, because the player stops without announcing it: paused turns true and no pause event fires.

What changes

  • Clicking or dragging the progress bar while a video plays keeps it playing from wherever you let go. While you hold the button down, the video waits on that frame, and it continues when you release, the way the browser's own video controls behave.
  • A paused video stays paused when you click or drag the bar.
  • Touch works the same way.
  • The play button keeps showing "pause" through the click.
  • Developers get the same choice for their own buttons and keyboard shortcuts: player.seek(30, { keepPlaying: true }) jumps without stopping. A plain player.seek(30) and player.currentTime = 30 still stop at the new time, exactly as before, so existing pages keep working the same way.
  • The package README and the docs page describe both.

Before

Clicking the bar at 65% while the video plays: it stops at 6.5 s. A drag afterwards leaves it stopped too.

Before: after the click the time stays at 6.5 s and the status reads paused

Watch the Before recording (MP4)

After

The same clicks: the video keeps playing from 6.5 s, and again from where the drag ends.

After: after the click the time keeps advancing and the status reads playing

Watch the After recording (MP4)

Test plan

Checked in Chrome 157 with the built player (dist/hyperframes-player.global.js) against a build of main, on four kinds of embed: an opaque-origin composition (sandbox-origin="opaque"), a same-origin composition, a composition that only registers window.__timelines, and an MP4 in video mode.

  • Clicking the bar at 70% while playing continues from 7.0 s (8.2 s one second later) on all four. Events arrive as pause then play, the same order Chrome's own <video controls> uses (pause, seeking, seeked, play, playing).
  • Dragging while playing continues from the release point.
  • Clicking or dragging while paused stays paused and fires no events.
  • A touch tap and a touch drag (Chrome touch emulation) continue playing with one pause/play pair.
  • The play button shows "pause" in all 89 frames recorded around a click.
  • seek(2, { keepPlaying: true }) while playing keeps playing and fires no events; seek(6) still lands paused.
  • bun run --cwd packages/player test (589 tests, 10 new, 6 of which fail on main), typecheck, oxlint, oxfmt, fallow, the comment checks and bun run verify:packed-manifests pass.

How

  • controls.ts: pressing the scrubber while playing pauses through the existing onPause callback and remembers that it did; releasing calls onPlay. The play button ignores state changes during that press. When a touch scrub ends, its touchend is cancelled so the browser doesn't follow the tap with mouse events, which would scrub a second time.
  • hyperframes-player.ts: seek(t, { keepPlaying: true }) runs the normal seek, then resumes through the internal play path (no second play event) if the player was playing or had a play queued while its assets loaded.
  • Size budget: packed 157,145 → 159,100. With CI's pnpm 10.17.1, main packs to 158,705 bytes, 11 under the old limit. This change adds 235 bytes of code and 104 bytes of README (159,044). The unpacked size stays within its budget.

Why seek() keeps pausing by default

Landing paused is seek()'s deliberate contract. Maintainer comments say so (heygen-com#750, heygen-com#4054), a test asserts it, and the loop wrap, play-range jumps and the scrubber's own settle rely on it. The runtime (heygen-com#863) and Studio (heygen-com#842) both made playing on an opt-in keepPlaying flag, so the player's API takes the same shape. The built-in controls are different: nothing chose for the scrubber to pause, it inherited seek()'s contract (heygen-com#890 noticed that a scrubber click pauses and made the audio match). CONTRIBUTING asks for default changes to be discussed, so treat this part as a proposal. If you'd rather keep today's scrubber behavior by default, it can move behind an attribute.

Notes

  • On the runtime paths, paused can read true for a few milliseconds after a keep-playing seek, until the runtime's next state message arrives. The same already happens after a loop wrap, and nothing changes on screen.
  • Clicking the very end of an MP4's bar while it plays restarts it, because play() at the end starts over (the player's existing rule, and the HTML one). Compositions end normally.
  • Open question: should setting currentTime keep playing, as it does on a <video>? This PR leaves it landing paused.

Related work

Open in Capy

miguel-heygen and others added 9 commits November 14, 2026 03:05
…pacity (heygen-com#7583)

* fix(studio): menus and popovers ease their scale in, not only their opacity

* style(studio): format the floatingMotion test
…en-com#7689)

skills/gsap was folded into the animation skill in heygen-com#1349, but build-copy
still listed it, so every CLI build printed "[build-copy] skill not found,
skipping: skills/gsap". Drop the entry, and add a test that every bundled
skill name has a SKILL.md so the next rename fails CI instead of warning.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…indings (heygen-com#7699)

A color-graded <video> is painted into a canvas the runtime inserts after it,
with a runtime-generated id (__hf_color_grading_<video id>). Layout findings on
that canvas (e.g. container_overflow for a punch-in inside an overflow:hidden
box) named the canvas, so the fix hint "mark intentional overflow with
data-layout-allow-overflow" pointed at an element the author never wrote.
The audit already reads flags from the authored video (authoredElement);
uniqueSelectorFor now names that element too, so the finding's selector,
data attributes and source file are the video's.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…es with (heygen-com#7697)

The settings line printed the encoder tier, so the default render said
"30fps · standard · …" while it encoded the looks preset at CRF 16, and
-q delivery said "high". A long-form render that came out at ~40 Mbps
read as "standard quality" in its own log.

The line now prints the --quality name as asked for and the CRF or
bitrate in effect: "looks (CRF 16)" by default, "looks (CRF 20)" with
--crf 20, "looks (10M)" with --video-bitrate 10M, "draft", "delivery".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…gen-com#7608)

`hyperframes init --non-interactive` closes with `cd <name> && npm run …`
lines for the person or agent to paste. The name went in as typed, so a
name with a space or a quote printed a command that fails as written:
`cd my first video` (bash: "cd: my: No such file or directory", zsh: "too
many arguments") and `cd it's mine` (an unterminated quote).

A new `shellArg` quotes a word only when a shell would split or expand
it: double quotes, or single quotes when the text holds `$`, a backtick,
`\`, `"` or `!`; on Windows, double quotes, with `\` paths left bare.
`hyperframes open` / `preview --stop` hints (desktopApp's `shownDir`)
already quoted whitespace with JSON.stringify and now use it too, so a
name like `it's` or `a&b` is covered there as well.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
heygen-com#7700)

duplicate_audio_track said two overlapping clips on one data-track-index
"cause layered audio playback" and suggested moving one to another track.
The render's mixer never reads data-track-index (every audible clip is its own
amix input) and the docs call the attribute a Studio lane only, so the two
clips are layered whatever their tracks: rendering a whoosh and a hit on
track 6, then with the hit on track 7, gives byte-identical audio. The message
now says both play and that the overlap is two clips sharing one Studio lane;
the hint says splitting tracks leaves the mix unchanged, and that a leftover
duplicate should be removed or moved. lint-rules.mdx said the audio "doubles
up"; it now says the same as the message.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Clicking or dragging the scrubber while a film played left it paused at
the new spot, and `paused` turned true without a `pause` event. The
controls now hold a playing film during the press and play on from where
it is released, like a <video>'s own controls; a paused film stays
paused. A touch scrub no longer replays as mouse events.

seek() gains an opt-in { keepPlaying: true } for hosts (keyboard seeks,
chapter jumps). A plain seek(t), and setting currentTime, still land
paused, so existing callers keep their behavior.

The packed player tarball grows 339 bytes (158705 to 159044, packed with
pnpm 10.17.1 as CI does). main sat 11 bytes under the 1% gate, so the
packed budget moves to 159100; the unpacked size stays within budget.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 267 (base branch 267), smooth 125 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

miguel-heygen and others added 4 commits November 14, 2026 04:15
…#7714)

* feat(studio): toasts show as a centred pill that rises in

* fix(studio): error toasts stay up on a click so their text can be copied
…gen-com#7716)

`hyperframes docs rendering` said workers run 1-8, but the engine
clamps an explicit value to 24, and it never mentioned --format or
--gif-loop. The options now match render.ts and the engine limit.

Fixes heygen-com#7668
…ygen-com#7715)

capture read --timeout with parseInt, so `--timeout 1s` became a 1 ms
page-load timeout. grade-compare dropped an invalid value without a
warning. Both now use the shared parseTimeoutMs helper that check and
snapshot already use, keeping their defaults of 120000 ms and 5000 ms.

Fixes heygen-com#7671
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants