Skip to content

Feature/player controls - #172

Open
erklu wants to merge 16 commits into
developfrom
feature/player-controls
Open

erklu wants to merge 16 commits into
developfrom
feature/player-controls

Conversation

@erklu

@erklu erklu commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Own player controls in place of the browser's bar

Base branch: develop.

Space sometimes played and paused the recording and sometimes did not. The cause was the native <video controls> bar: it handles keys inside itself, differently in each browser, and the page cannot reliably intercept them. Which thing had focus decided whether Space meant "play", "type a space" or "press this button".

The rule

A bare Space never plays or pauses the recording from the page. Ctrl+Space always does. This is now true because of how the page is built, not because something has to be watched for:

  • The editor's <video> has no controls attribute and so no tab stop; PlayerControls replaces the bar.
  • The head script's handler that played and paused on a bare Space whenever nothing was being typed into is removed. It must not come back; a test says so.
  • On a focused button Space presses that button (including play/pause); in the text it types a space.
  • Ctrl+Space stays SRTEditor.handle_key_event's. Because a focused button is also pressed when Space is released, the controls stop the browser's own action on Ctrl+Space (preventDefault only; the event still reaches the page), or the two toggles would cancel out.

What the controls are

Laid over the foot of the picture, inside the video frame, on a dark tone, the way the browser's own bar is. Two rows: the position bar with the time on either side, and under it the buttons. All white, in both themes, because what they sit on is a video.

  • Play/pause. Tooltip "Pause/Play (Ctrl + Space)".
  • Play with context, a speech bubble with a play triangle (Material Symbols auto_read_play, in the font NiceGUI already ships). Tooltip "Play with context (Ctrl + Shift + Space)".
  • Speed, 0.5-2x, the bare figure ("1×") with a menu (menu / menuitemradio, arrow keys, Escape returns focus). Not remembered between sessions. It follows into play with context, whose stop is decided on position, never on time.
  • Volume: a mute button and a slider (the slider is dropped under 20rem, the button stays).
    The times, the speed and the menu are in the page's own type (14px, weight 500, as the "Uncertain words" choices beside the video), with tabular numerals so the figures do not jitter.

No fullscreen, picture-in-picture or download. The bars are native range inputs, so the keyboard, the pointer and screen readers get what the browser does for a slider: arrows move 5 s, Page Up/Down 30 s, Home/End the ends; each has a name and a spoken value (12 minutes 34 seconds of 1 hour 23 minutes), and the figures beside the bar are aria-hidden so they are not read twice.

Everything the controls show is read off the video element, so it is right whoever started playback (the button, Ctrl+Space, the timeline, a caption played with its context). Nothing is sent to the server for it.

The position follows timeupdate (about four times a second), not an animation-frame loop. That was tried first and measured at about 11% of a core in headless Chromium while playing, against about 1% on timeupdate and 0.1% for the bare video; the time changes once a second and the thumb, over a recording of any length, a fraction of a pixel, so nothing visible was lost.

The volume slider is only drawn where the browser lets a page set the volume (iOS never does; the mute button stays).

A mouse press on the buttons does not take focus (the same reason the Highlight button does it), so the reader can click Play and keep typing, and a Space after the click is a space in the text.

Going away and coming back

While the video plays and nothing is being done to the controls they fade out (3 s after the pointer last moved over the frame, 1 s after it leaves, not while the speed menu is open, never for a recording without video). The pointer, a tap on the video, keyboard focus anywhere in them (:focus-within) or pause brings them back. Asleep they are also pointer-events: none, and the click that follows a tap which woke them is swallowed (touch and pen only), so waking them never presses the button that was under the finger. The subtitle preview rises clear of them while they are up and drops back to the foot of the video when they go (:has(), short transition, none for prefers-reduced-motion).

Play with context

The button has no caret to read the block from, unlike the shortcut. TranscriptEditor.play_with_context_here() plays the block the caret last settled in (caret_block, kept when the caret leaves); before the caret has been anywhere, or if that block has gone, the caption under the playhead; with neither it says so. The icon is the single CONTEXT_ICON constant in player_controls.js.

Audio files

A recording that is sound alone (videoWidth === 0 once the header is read) turns the frame into the player's own dark surface: no video and no subtitle preview, the file name where the video was, and the same two rows under it. The video's controls are the audio player's laid over a video, which is what makes them siblings. The speed menu opens downward there.

Colours and contrast

  • The unfilled track is half-white on the tone, which is dense under the rows. Measured against a white picture it is 4.1:1 to what is behind it (1.4.11); a flatter tone would not hold 3:1, so if the gradient is touched that needs measuring again.
  • The focus ring is white. The page's own is the brand colour, which is nearly the colour of the tone. The page's rule is !important in @layer theme, so this one has to be in that layer and specific enough to win.

Also in this branch

fix/timeline-strip-width (4382ac5, also open as its own change): .speech-timeline-wrap got container-type: inline-size in the tray change, which gives it no width of its own; in the editor's column (a flex column aligned to the start) it came out 0 wide and the strip and legend with it. It is given width: 100%. If that change is merged first this branch's diff is clean of it.

How it was checked

tests/test_player_controls.py, 40 tests: the video has no controls, the controls' place in the frame, the removed Space handler and overlay script, the Ctrl+Space guard on both halves of the key, names/roles/values of every control, the speeds, the audio-only path, the look and the type, the fade (both ways), the white ring, the menu fitting a short frame, and play_with_context_here (caret block, a block that has gone, the playhead fallback, nothing to play, no edit, nothing on screen). Existing tests for the old Space handler and the overlay script were removed or rewritten. Full suite: 1805 passed (tests/test_recording.py excluded).

In Chromium against a real video, a white video and the Material icon fonts NiceGUI ships, with real key presses: Space on the focused Play button plays and pauses; Ctrl+Space on it toggles exactly once; Space in a text area leaves the player alone; Enter/Space/click on Play with context emit once, Ctrl+Shift+Space on it emits none; the speed menu opens on the speed in force, arrows and Enter pick, Escape returns focus; the bar moves by 5 s / 30 s / End; the controls go while playing and come back for the pointer and for Tab; narrow (360 px), dark, and an audio file.

In the real app, in Chrome, light and dark: the controls sit in the frame with the frame's rounded corners; Ctrl+Space from the text pauses once; Space on the focused Play button plays; Ctrl+Space on it toggles once; Play with context from a click plays the block's context; the speed menu opens upward with the right item marked; the controls fade while playing and the preview drops.

With the timeline docked at the foot of the page: the controls are unaffected, the in-column strip's place closes up, and the column reads video, then the switches.

Not checked

  • An audio file in the real app (only in the harness).
  • A narrow splitter in the real app (only a 360 px container in the harness, where the menu scrolls inside the frame).
  • Chrome only. Firefox and Safari were not run; the slider styling has Firefox pseudo-elements that were not looked at, and neither was whether Ctrl+Space on a focused button behaves the same there. Neither was a real keyboard on a Mac.
  • Not tried with a screen reader.
  • Not tried on a real touch device. Touch emulation in Chromium (tap to wake, tap on the hidden Play position, second tap) passes; iOS Safari was not run. The targets are 2rem (the main button 2.25rem), smaller than the usual touch size. On a phone the viewer page (native bar) is expected to be used rather than the editor.
  • Ctrl+Space in the comment box still does not reach the page (that textarea stops all key propagation); unchanged by this.
  • The viewer page (pages/view.py) is a different page and keeps its native bar.
  • Three comments (in pages/srt.py, utils/common.py, utils/srt_render.py) describe the F-74 workaround as guarding against the native Space shortcut, which no longer exists. The workaround is harmless and was left alone.

erklu and others added 14 commits October 20, 2026 14:30
The native bar handled keys inside itself, differently per browser, and the
page could not reliably intercept them, so Space played and paused the
recording with one thing focused and typed or pressed with another. The
editor's video now has no controls attribute, and a PlayerControls pill under
the picture replaces the bar: play/pause, "Play with context", speed (0.5-2x,
a menu), a position bar with the time beside it, and the volume.

The rule is now the same everywhere: a bare Space never plays or pauses the
recording from the page (the head script's handler is removed), and
Ctrl+Space always does. A focused button is kept from also being pressed by
Ctrl+Space, which would cancel the toggle out.

"Play with context" plays the block the caret was last in, or the one under
the playhead before it has been anywhere. A recording that is sound alone
drops the picture frame and shows the file name under the controls. The
subtitle overlay no longer has a bar to clear, so the script that raised it
is removed. The icon for "Play with context" is a standard Material Symbols
stand-in, one constant to change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
A container with inline-size containment has no width of its own to
offer. In the editor's column, a flex column that aligns its children
to the start, the wrap came out 0 wide and the strip and legend with it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
The controls move from a pill under the picture into the video frame,
laid over its foot on a dark tone as the browser's own bar was, with the
position bar above and the buttons below. They go while the picture
plays and nothing is being done to them, and come back for the pointer,
for keyboard focus, on pause, and always where there is no hover. The
subtitle preview rises clear of them while they are up.

A recording with no picture turns the frame into the player's own dark
surface with the file name above the same rows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
Measured in headless Chromium, the animation-frame loop that moved the
thumb cost about a tenth of a core while playing, against about a
hundredth on timeupdate (four times a second), for movement nobody can
see: the time changes once a second.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
A tap that wakes the faded controls no longer also presses the button
under the finger, and the controls are out of reach while asleep. The
position bar no longer freezes after a click on its thumb that does not
move it, and the controls do not fall asleep mid-drag. The volume slider
is left out where the browser cannot set the volume (iOS).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
…ting shortcuts only)

Spell out that the player carries only what the video element does itself
plus buttons that fire an existing shortcut, and that the waveform idea was
decided against, so later changes are weighed against upkeep.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
… away

The button now plays around where the recording is: the caption under the
playhead with its context, or, in a pause, in a stretch not marked as
speech or in a recording without word timings, the words around the moment
or the plain window of five seconds either side. It no longer reads the
caret (which stays "last settled in" and would have beaten the playhead
for good) and no longer answers with a message.

A press from where the last one stopped is the same stretch again, so the
stretch does not walk forward a caption at a time.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
…der's own

A press plays the same stretch again if it is running, or has stopped and
neither the playhead nor the caret has been moved since. The player keeps
that state next to the video (set by the range script, cleared on a pause,
a seek or playing on), the editor clears it when the caret settles in
another caption. This replaces the position tolerance, which could not tell
a stretch still running from one the reader had moved to.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
@erklu
erklu changed the base branch from main to develop October 21, 2026 07:48
erklu and others added 2 commits October 22, 2026 10:35
The overlay shows what a viewer sees while the recording plays, so its
on/off belongs with the player rather than among the editor's own
markings, and the player's own controls make that possible.

The button sits between "Play with context" and the speed, for
subtitles only (a transcription has no overlay and gets no button). It
is a toggle button: one name ("Subtitles") with aria-pressed, the
closed-captions box when on and the struck-through one when off, so the
state is in the shape and not only in a colour. It flips at once and
emits "captions"; the page keeps the setting as before and redraws the
overlay without waiting for a timeupdate. The "Subtitle overlay" switch
is removed.

AGENTS.md: the player's scope now reads "a shortcut or a setting the
service already has", and the CC button is described.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
The audio surface hides the subtitle overlay (there is no picture to lay
it over), so the button would change a setting with no visible effect.
It is now only drawn for subtitles over a picture.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LCjpD3ehigLiUGH1oVQQAW
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.

1 participant