beecast-player 0.5.0

The first-party beecast player — a clean-room, dependency-free asciicast (v1/v2/v3) player and VT emulator, exposed as inlinable JS/CSS string constants, with live-follow append for recordings that are still growing.
Documentation

beecast-player — the first-party beecast player

A self-contained, dependency-free player for asciicast recordings (v1, v2, and v3): a DOM-free asciicast parser and VT100/xterm-subset terminal emulator, plus a thin DOM half with the renderer, playback clock, and controls. The crate exposes the component as two string constants to inline — PLAYER_JS (one <script>) and PLAYER_CSS (one <style>); nothing is fetched at runtime, no workers, no fonts, no images.

This crate is the component's canonical home. The component was born in scsh's session browser and graduated here; scsh is now one downstream consumer among any others. beecast pages embed it through beecast-page; any other page or app that plays asciicast recordings consumes it from crates.io the same way.

Clean-room statement. Written from scratch against public format and protocol documentation only — the asciicast v1/v2/v3 format descriptions and the standard ECMA-48 / xterm control-sequence references. No asciinema-player source code was consulted, copied, or translated. MIT, like the rest of beecast, so every embedding page carries a single license.

Layout

File Role
src/vt.js The portable core. Asciicast parsing (v1/v2/v3) with live-follow appends, the VT100/xterm-subset terminal emulator, and the pacing map. Pure state machines: bytes in, screen snapshot out. No DOM, no timers, no globals — runs in a browser or Node unchanged.
src/player.js The thin DOM half: renderer (snapshot → HTML runs), playback clock (idle-time compression, speed), controls (play/pause, seek bar with chapter markers, keyboard shortcuts), the live-follow policy, and the public API.
src/player.css Terminal palette + player chrome. All colors are CSS variables, themeable by the embedding page.

The two JS files are concatenated at compile time into the one PLAYER_JS constant.

// Inline both constants whole; the page stays fully self-contained.
let js = beecast_player::PLAYER_JS;
let css = beecast_player::PLAYER_CSS;

Public API

const player = BeeCastPlayer.create({ data: castText }, mountElement, {
  fit: 'both',        // scale the terminal to the container (width, or width + height)
  controls: true,     // render the control bar (default true)
  idleTimeLimit: 2,   // cap silent gaps at N seconds of playback time
  markers: [[t, 'label'], …],  // chapter ticks on the seek bar (the recording's own
                      //   in-band "m" markers are added automatically, at load and live)
  startAt: 12.5,      // seconds, or a 'mm:ss' string
  speed: 1.5,         // initial speed (one of 0.5, 1, 1.5, 2, 3, 5)
  autoPlay: true,     // start playing immediately
  fullscreenEl: el,   // what the ⛶ button / `f` key fullscreens (default: the player);
                      //   pass a wrapper to bring the page's own chrome along
});
player.play();
player.pause();
player.seek(t);            // seconds, or 'mm:ss' — always in RECORDING time
player.getCurrentTime();   // seconds, in RECORDING time
player.append(text);       // live-follow: newly produced cast lines (see below)
player.dispose();

The time axis is always recording time. Idle-time compression only affects pacing (long silences play back at most idleTimeLimit seconds long); seek, getCurrentTime, markers, and ?t= deep links all use the recording's own clock, so chapter sidecars and share-links stay aligned no matter the compression.

Layout. With fit set, the fixed-metric terminal scales down (never up) to the containing box's width — and, for fit: 'both', also to the mount's height when the embedding page gives it one. Whenever the terminal (scaled or not) ends up narrower than its pane, it is centered horizontally in it.

Keyboard, when the player has focus: space play/pause · ←/→ seek ±5s · < / > speed down/up · [ / ] previous/next marker · c chapters panel · f fullscreen.

The big play button. While the recording sits at its very start, a large center play glyph — built from block characters, because this is a terminal player — dims the screen behind it; one click starts playback. It never appears mid-recording, so a live player parked at the growing edge stays undimmed.

Chapters are part of the player: when the recording has markers (from opts.markers or in-band m events, including ones that arrive live through append), a ☰ button joins the control bar and toggles a fixed, scrollable panel over the screen's right edge — one row per chapter, click to jump there and play. The c key toggles it too.

Speed is a menu, not a cycle: the button opens the fixed speeds in a small menu growing up from the bar (fastest on top, current highlighted); < / > still step through them from the keyboard.

Fullscreen is part of the player: the ⛶ button at the right end of the control bar (and the f key) fullscreens fullscreenEl — or the player itself — and the terminal refits and centers in the viewport. Offline pages built with beecast-page get it with no page code at all.

Live-follow

A recording that is still being produced can be followed as it grows: feed each new chunk of v2/v3 NDJSON lines to player.append(text). How the data arrives — WebSocket, polling, a tailed file — is the caller's business; the player owns everything after that:

  • Chunk boundaries are free. Only complete (newline-terminated) lines are consumed; a partial trailing line — including one already present when the player loaded a file cut mid-write — is buffered until its remainder arrives. Stray header replays and # comment lines are skipped.
  • The follow policy is positional, like tail -f. A playhead resting at the live edge stays pinned to it and renders each append immediately. A viewer who paused earlier or seeked back is never yanked forward — they just watch the duration grow. A playing player keeps its own clock; the longer recording simply no longer auto-pauses it at the old end, and once playback catches the edge and parks there, subsequent appends pick it up and follow.
  • A player mounted on an empty live cast (header only, duration 0) follows from the first byte with no extra configuration.
  • In-band m (marker) events become chapter ticks as they arrive, so the seek bar shows the same chapters live as it would after a reload.

v1 recordings are a single JSON document with no line to append to; append on them is a no-op by design.

The core half is exposed on BeeCastVT for embedders that need it without a mounted player: parseCast(text), appendCast(cast, text), buildPacing / extendPacing / mapTime, and new Term(cols, rows) with .write(text), .resize(c, r), .snapshot().

Terminal emulation scope

The subset a tmux-hosted TUI actually exercises: cursor addressing (CUP/CUU/CUD/CUF/CUB/ CHA/VPA/CNL/CPL), erase (ED/EL/ECH), insert/delete (ICH/DCH/IL/DL), scroll (SU/SD, DECSTBM scroll regions, IND/RI/NEL), SGR (16/256/true color, bold, dim, italic, underline, inverse, strikethrough), alternate screen (?1049, ?47), cursor visibility (?25), autowrap with deferred wrap (?7), save/restore cursor (DECSC/DECRC, CSI s/u), DEC special graphics (ESC ( 0 line drawing, SO/SI), tab stops, OSC consumption (titles are parsed and ignored), and v3 in-band resize events. Unrecognized sequences are consumed and ignored — never rendered as text.

Testing

The DOM-free core self-tests under Node from cargo test (vt_core_node_selftest shells out to node and skips silently when Node is not installed): parsing, the emulator subset, live-follow appends across hostile chunk boundaries, and the pacing map. A structural test (player_bundle_is_inline_safe_and_first_party) gates the properties every self-contained embedding depends on — no </script, no workers, no CSS fetches, and no third-party license marker anywhere in the bundle.

License

MIT (text in LICENSE, shipped with the crate) — the component, like the rest of beecast, is all first-party code.