Susegad UI
Register
Theme
Palette

Components

Player

The Susegad player: enhances a native video or audio element with a drawn scrubber, captions in our type, a poster that is a scene's still, WebGL video treatments (ink, halftone, duotone, riso) and timed ink annotations on the moving frame. Content sound plays only on its own play press.

quiet warm playful

Open the live demo npx susegad add player

Stands on: Core, Core: components, Engine, Narration, Tokens

The prompt

the prompt

A window that has been painted over: the moving picture is still there, but you're watching it the way you'd watch rain through the glass.

The Susegad player: a light-DOM element that enhances a native <video> or <audio> so the no-JavaScript page is a full, working player. With JavaScript it replaces the browser's chrome with a drawn scrubber, shows captions in our own type, opens on a poster that is a scene's still frame, and can pass the video through a shader treatment (ink, halftone, duotone or riso) with ink annotations timed to the moving frame.

<link rel="stylesheet" href="susegad/player/player.css">
<script type="module" src="susegad/player/player.js"></script>

<sg-player poster-scene="kolam" treatment="ink">
  <video controls preload="metadata">
    <source src="spread.webm" type="video/webm">
    <track kind="captions" src="spread.vtt" srclang="en" default>
  </video>
</sg-player>

The prompt

Build a video (or audio) player as a light-DOM custom element, <sg-player>, that enhances a native <video controls> or <audio controls> child, so a page with no JavaScript still plays, scrubs and mutes with the browser's own chrome. With JavaScript, hide the native controls and build your own bar: a play button, the time and duration in tabular numerals, a native <input type="range"> for seeking (so keyboard and screen-reader support come for free), a mute button and a captions toggle, each labelled for a screen reader. Draw the scrubber in three registers over the (visually hidden, still functional) range input: quiet leaves it a plain hairline track; warm draws a warm hand-inked line under the played portion, over a faint pencil guide for the rest, using the engine's own ink(); playful adds a small ringed bead riding the playhead, the way a bead sits on a kolam's line. Read captions from a <track kind="captions">, set its mode to hidden so the browser draws nothing of its own, and render the active cue yourself in the library's type, always present and on by default, toggled with the C key. Take a poster-scene attribute naming a Susegad scene; render that scene's finished still to a canvas once, show it as a drawing poster over the video, and fade it out the moment playback starts. Support a treatment attribute (ink, halftone, duotone, riso): pass the playing video's frames through a WebGL shader that samples the video as a texture and redraws it in that look, using colours converted from the tokens' OKLCH into linear RGB; when WebGL is missing, show the plain video rather than a broken canvas. Accept a list of timed ink annotations, each a circle or an arrow at normalised coordinates between a start and an end time, fading in and out at each edge, and draw them on a transparent canvas registered over the moving frame. Handle Space to play or pause, the arrow keys to seek five seconds (fifteen with Shift), Home and End to jump to the ends, M to mute and C for captions. Content sound plays only on its own play press: never unmute or start audio without that gesture, and only offer muted autoplay when the author sets autoplay and the viewer hasn't asked for reduced motion.

Words to code

When you sayTechniqueWhat happens
enhances a native <video controls> … childprogressive enhancementThe native element keeps its role, keyboard and no-JS behaviour; connected() only removes controls and builds the custom bar once JavaScript has actually run.
a native <input type="range"> for seekingnative-first accessibilityThe range input gets arrow-key, Home/End and screen-reader slider behaviour from the browser; player.core.js's scrubberFraction/timeFromFraction are the only maths needed to keep it and the video in step.
draws a warm hand-inked line … using the engine's own ink()reuse (A6)skins/warm.js and skins/playful.js paint onto a small canvas beside the native range, with the engine's ink() — the same stroke every scene draws with, not a second implementation.
render that scene's finished still to a canvas oncereuse, one-shotposter.js mounts a throwaway <sg-scene>, calls its own still(), copies the canvas it drew, and tears the scene down: a real Susegad drawing, not a video's first frame.
a WebGL shader that samples the video as a textureGPU pass, honest fallbacktreatments.js uploads each video frame with texImage2D and runs one of four fragment-shader looks; ok: false (no WebGL, a lost context) shows the plain video instead of a blank canvas.
colours converted from the tokens' OKLCH into linear RGBthe one conversion pointtreatments.core.js's oklchToLinearSrgb/oklchToRgb are the tested source of truth; the GLSL mirrors the same maths because a shader can't import JavaScript.
a list of timed ink annotations … fading in and outpure timing, then inkannotations.core.js's activeAnnotations is a pure function of a time and a list, tested in Node; annotations.js turns what it returns into ink() strokes on a canvas over the frame.
Content sound plays only on its own play pressconsent (decision 0015)The element never calls .play() or unmutes on its own; autoplay is honoured only muted, and even then only when prefers-reduced-motion is off.

Wraps a native <video> or <audio>. Without JavaScript the browser's own controls play, scrub and mute the media in full. With JavaScript, <sg-player> removes controls and builds its own bar: a play button, the time, a seek range, a captions toggle and a mute button.

<sg-player poster-scene="kolam" treatment="ink" label="The story of the first rains">
  <video controls preload="metadata">
    <source src="spread.webm" type="video/webm">
    <track kind="captions" src="spread.vtt" srclang="en" default>
  </video>
</sg-player>

Attributes

AttributeWhat it does
registerquiet, warm or playful, inherited from an ancestor by default. Sets how the scrubber is drawn.
labelThe video or audio's accessible name (aria-label).
poster-sceneA scene name (kolam, paus, tollem, …). Its finished still is rendered once and shown as the poster, fading out the moment playback starts.
treatmentink, halftone, duotone or riso. A WebGL shader pass over the video in that look. Video only; falls back to the plain video with no WebGL.
annotations-srcA URL to a WebVTT metadata track of timed ink annotations (see below).
autoplay (on the native <video>)Honoured only muted, and only when the viewer hasn't asked for reduced motion. Content sound still plays only on the person's own play press.

Captions

Add a <track kind="captions" src="…vtt" default> inside the video or audio. <sg-player> sets its mode to hidden (the browser draws nothing of its own) and renders the active cue in the library's own type, over the bottom of the frame, on by default. Press C, or the CC button, to hide or show them; they stay in the DOM and in the accessibility tree either way. <track> timestamp tags inside a cue's text (word-level highlighting, as packages/narration writes) are stripped for display.

Ink annotations

document.querySelector('sg-player').annotations = [
  { type: 'circle', start: 4.2, end: 7.5, x: 0.62, y: 0.4, r: 0.08, label: 'the leak' },
  { type: 'arrow', start: 8, end: 11, x: 0.2, y: 0.8, x2: 0.55, y2: 0.35 },
];

x, y (and x2, y2 for an arrow) are normalised 0 to 1, independent of the video's pixel size, so an annotation stays in place as the player is resized. Each fades in and out over about a quarter of a second at its own edges. annotations-src loads the same shape from a WebVTT metadata track instead, one JSON object per cue:

WEBVTT

00:00:04.200 --> 00:00:07.500
{"type":"circle","x":0.62,"y":0.4,"r":0.08,"label":"the leak"}

Video treatments

treatment="ink" (a two-tone wash), "halftone" (a rotated dot grid), "duotone" (two token colours mixed by luminance) or "riso" (three inks, each sampled with its own small offset, the way overprinted risograph plates misregister). Colours come from --sg-pencil, --sg-surface and --sg-accent, resolved for the page's palette and theme. With no WebGL, or a lost context, the plain video shows: never a blank or broken canvas.

Keyboard

Space or K play or pause; ←/→ seek 5 seconds (15 with Shift); Home and End jump to the start and end; M mutes; C toggles captions. The seek range also takes its own Left/Right/Home/End as any native slider does.

Events and properties

<sg-player> fires nothing of its own; listen to the native <video>/<audio> events (play, pause, timeupdate, ended, …) directly — the element never hides them. annotations (get/set) is the current list, normalised. The wrapped media element is player.querySelector('video, audio').

Accessibility

The play, mute and captions buttons are labelled and reflect their state (aria-label, data-state). The seek range has aria-valuetext naming the position in words ("1:02 of 4:15"). Captions are always in the accessibility tree. The scrubber's drawn ink is aria-hidden; the range input underneath is the real control.

Registers

Quiet leaves the native range a plain hairline. Warm draws a warm ink line under the played portion over a faint pencil guide. Playful adds a small ringed bead at the playhead. None of this changes behaviour, only how the same scrubber looks.