One video surface: waveform scrubber, chapters, diarized speakers, bookmarks and transcript — every intelligence prop optional
Scroll over the band to zoom (double-click or Esc resets); scroll over the video for volume (Shift + scroll seeks). Hover a legend chip to raise that speaker on the timeline, click it to solo, and use the speaker icon to skip them. Chapter flags sit on the top edge, bookmark diamonds on the bottom.
With an envelope the band owns the time axis: the native timeline stands down (Chromium). Scroll to zoom and a whole-video overview rail appears above the band with the zoomed window bracketed; click it to jump. The warm wash under the envelope is the light ribbon — bright passages glow before you reach them. Drag along the band to scrub (it replaces the native timeline, so it scrubs like one). Comment dots ride under the chapter flags (hover to read, click to jump, or { / } for previous / next — announced to screen readers); likes fold into a heat strip along the bottom edge.
onBandChange → (waiting for metadata)
A meter, not a preference: about once a second the player reads the frame's actual
brightness and pulls the picture down in proportion, warming it through multiply layers so
blacks stay black — a dark scene passes through untouched. auto arms it from the
local clock (20:00–07:00), on always, off never; press N or click the moon under the band to cycle, scroll the moon to
set the depth. The light track (luma) lets dimming start before the white-out
at 6s, and its flash interval (3–4.5s) is held down by the strobe guard. While dimming, the
frame's own colours glow softly behind the player; on a light theme the player's surround is
pulled down with the picture. Needs a CORS-readable video (crossorigin="anonymous").
Without an envelope the band falls back to a flat rail, and the slim lane beneath it carries who-spoke-when. Only ever one speaker surface, never two stacked strips saying the same thing.
Every extra prop is optional. With none of them you still get the band as a real scrubber (hover for time, click to seek, arrows to step) plus wheel volume and the keyboard map.
With syncUrlTime on, a ?t= param seeds the start position and
playback keeps it in step (replaceState, whole seconds, other params preserved) — refresh
or share the URL and it picks up where you left off. crossorigin="anonymous" passes through to the <video> — what frame-reading features need.
Press ? for the full keyboard map (speed steps, frame step,
chapter jumps, bookmark, transcript toggle).
A 2h talk is 2–3k transcript rows; this instance mounts 1,800 of them but only the rows near the viewport exist in the DOM (scroll it — the player auto-follows the active segment as it plays). Speaker identity colours are derived from the active theme's semantic tokens and lifted to WCAG AA against the surface, so they follow the ThemeSelector — switch theme in the header and the same speakers change colour with it.
The same component is the presentation primitive: aspect locks the stage box from
the first frame (poster convention — the poster fills the ratio until the first frame decodes), autoplay starts it muted (honouring prefers-reduced-motion — under that
preference it holds on the poster), and loop repeats it.
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | — | Video URL (required) |
poster | string | null | null | Poster frame |
captionsSrc | string | null | null | VTT captions track |
chapters | VideoChapter[] | [] | Flags on the top edge; sections in the envelope; hover card titles |
speakers | VideoSpeakerTurn[] | [] | Diarized turns — colour the envelope, or the fallback lane |
transcript | VideoTranscriptSegment[] | [] | Transcript pane + "what's being said here" on hover |
bookmarks | VideoBookmark[] | [] | Diamonds on the bottom edge |
waveform | VideoWaveform | null | null | Audio envelope; peaks normalise against their own max |
talkTime | VideoSpeakerTalkTime[] | [] | Precomputed totals; derived from turns/transcript when omitted |
startAt | number | null | null | Seek here once metadata lands |
transcriptPosition | 'below' | 'side' | 'below' | Transcript beneath, or beside on wide viewports |
luma | VideoLumaTrack | null | null | Light track (0–99 at rate/s) — paints the light ribbon under the envelope |
markers | VideoMarker[] | [] | Comment dots (hover/click) and a like heat strip on the band |
oneAxis | boolean | true | Band owns the axis while it has an envelope; zoomed ⇒ overview rail with bracket |
onBandChange | (state: BandState) => void | — | Four-state arbitration (band/overview/bar/both), real changes only, microtask-deferred |
onMarkerClick | (marker, cluster) => void | — | A comment dot was clicked (after the seek to the earliest); cluster = every comment merged into that dot |
night | 'auto' | 'on' | 'off' (bindable) | — | Night dimming. Omitted ⇒ the feature doesn't exist here (no N, no button, no layers) |
nightDepth | number (bindable) | 0.7 | 0.2–1; depth 1 takes a white frame to about half brightness |
nightWindow | { from, to } | null | null | Local hours for auto (default 20–7; 18–8 when the OS prefers dark) |
biasLight | boolean | true | The frame's own colours, blurred, behind the player while night acts |
predictive | boolean | true | Read luma ~1s ahead so dimming starts before a bright cut (only ever adds dimming) |
strobeGuard | boolean | true | Hold the level down across luma.flashes — independent of night |
onNightModeChange | (mode) => void | — | N / the moon button changed the mode (persist it) |
onNightBlind | () => void | — | Frame readback refused (CORS) — night fell back to a fixed level |
onTimeUpdate | (video) => void | — | Every timeupdate tick (watch-progress reporting) |
onAddBookmark | (sec, label) => void | — | Present ⇒ renders the add-bookmark control |
onBookmarkContext | (sec, existing, x, y) => void | — | Right-click on the band; the host owns the panel |
night)