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, a headless playback
controller, and a thin DOM half with the default controls and <beecast-player> Web
Component. 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/controller.js |
Headless playback controller (BeeCastController). Owns cast state, terminal, pacing, clock, markers, and subscribers. Injectable scheduling for deterministic tests. No DOM. |
src/player.js |
DOM view over the controller, default controls, <beecast-player> custom element, and the legacy BeeCastPlayer.create factory. |
src/player.css |
Terminal palette + player chrome. Semantic --beecast-* tokens are the stable theming surface; --sp-* is the terminal palette. |
The three 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 = PLAYER_JS;
let css = PLAYER_CSS;
Integration levels
- Zero-config factory (legacy, still fully supported):
const player = ;
player.;
player.;
player.;
player.; // in place — does not remount
player.; // recording seconds, synchronous
player.; // snapshot: status, time, markers, terminal, …
player.; // immediate + changes; returns unsubscribe
player.; // live-follow
player.;
- Web Component (preferred for new browser embeddings):
- Headless controller (custom UI, tests, non-DOM hosts):
const controller = ;
const stop = controller.;
controller.;
controller.;
controller.;
controller.;
controller.;
Compatibility notes (public vs internal)
Public today
BeeCastVT:parseCast,appendCast,buildPacing,extendPacing,mapTime,Term, attribute bit constants,color256.BeeCastController.create(...)and its command/state/subscribe contract.BeeCastPlayer.create(...)methods:play,pause,toggle,seek,getCurrentTime,setSpeed,getState,subscribe,append,dispose.<beecast-player>element: properties/methods/events listed below.- CSS variables listed in
BeeCastPlayer.supportedCssVariables(semantic--beecast-*plus terminal--sp-*).
Readable but not public (migration window only — do not depend on these):
player.playing,player.pacedPos,player.eventIdx, and other internals listed inBeeCastPlayer.nonPublicFields. PrefergetState().status === 'playing'orsubscribe.
getCurrentTime() and seek() are synchronous. If a future source needs async seeking, that will be a separately named method — these contracts will not change silently.
Playback state
interface PlaybackState {
status: 'idle' | 'playing' | 'paused' | 'ended';
currentTime: number; // recording seconds
duration: number;
speed: number;
atLiveEdge: boolean;
canAppend: boolean;
markers: TimelineMarker[];
terminal: TerminalSnapshot;
dimensions: { columns: number; rows: number };
}
getState() returns a defensive snapshot. subscribe(listener) invokes the listener immediately with the current state, then on meaningful changes. High-frequency timeupdate notifications are coalesced to animation-frame rate; discrete events (seek, speedchange, durationchange, …) always deliver. The unsubscribe function is idempotent.
Events (CustomEvent on the player root / <beecast-player>)
| Event | Typical detail |
|---|---|
beecast-ready |
{ state } |
beecast-play |
{ origin, currentTime } |
beecast-pause |
{ origin, currentTime } |
beecast-timeupdate |
{ currentTime, duration, atLiveEdge } |
beecast-seek |
{ origin, currentTime, duration } |
beecast-durationchange |
{ duration } |
beecast-speedchange |
{ speed, origin } |
beecast-markerchange |
{ markers } |
beecast-markerselect |
{ marker } (cancelable) |
beecast-ended |
{ currentTime, duration } |
beecast-liveedgechange |
{ atLiveEdge } |
Times are always recording time. Origins include 'api', 'keyboard', 'pointer', 'marker', 'source'.
Markers
Tuples [time, label] still work and are normalized. Preferred form:
interface TimelineMarker {
id: string;
time: number;
type: 'chapter' | 'annotation' | 'event' | string;
label: string;
description?: string;
color?: string;
source?: 'cast' | 'sidecar' | 'integration';
data?: unknown;
}
In-band m events get source: 'cast' and stable ids. Sidecar chapters use source: 'sidecar'.
Sources
type CastSource =
| { type: 'text'; data: string }
| { type: 'custom'; subscribe: (sink) => unsubscribe };
The base player never fetches because a string looks like a URL. Network-backed adapters are caller-supplied; generated pages use only inline text and perform zero network requests.
Theming
Stable semantic tokens:
--
Built-in themes via data-theme="dark" | "light" | "system" on the player root (or the theme attribute on <beecast-player>). Terminal ANSI colors (--sp-c0…--sp-c15) remain independently themeable. Internal .sp-* classes are not a public API.
Controls configuration
controls: true | false |
Accessibility
- Icon-only controls expose accessible names (
aria-label) independent oftitle. - Play/pause uses
aria-pressed; speed options usearia-checked. - Seek supports Arrow keys, Home, End, Page Up/Down.
:focus-visiblestyles use--beecast-color-focus/ accent.accessibility: 'snapshot' | 'off'— snapshot mode exposes the current terminal as off-screen preformatted text (not a live region, so playback does not flood assistive tech).prefers-reduced-motiondisables overlay motion.
Keyboard when the player has focus: space play/pause · ←/→ seek ±5s · < / > speed down/up · [ / ] previous/next marker (seek only — does not autoplay) · c chapters · f fullscreen · Escape closes menus.
Time axis, layout, live-follow
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.
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 the mount a definite height (%, vh, flex/grid stretch,
fullscreen). A content-sized mount (height comes from the player itself) is width-only:
vertical fit against a height the player controls is a ResizeObserver shrink ratchet.
Whenever the terminal (scaled or not) ends up narrower than its pane, it is centered
horizontally.
The big play button. Whenever playback is not running — at the start, paused mid-recording, or ended — a large center play glyph (block characters shaped as a triangle) dims the screen behind it; one click starts (or resumes) playback.
Live-follow. Feed each new chunk of v2/v3 NDJSON to append(text). Chunk boundaries
are free; partial trailing lines buffer until complete. A playhead at the live edge stays
pinned (tail -f policy); a viewer who seeked back is never yanked forward. v1 append
is a no-op.
Declared-live mode. player.setLive(true) (also on the controller) is for the
embedder that KNOWS the recording is still being produced: the playhead parks at the
growing edge — every append renders immediately, pinned unconditionally — and the seek
bar renders full-width in the live color (--beecast-color-live), reading as "now"
rather than a position that jitters as the duration grows. Any explicit rewind — a seek
before the edge, or play() (which would replay from the top) — drops live mode with a
livechange event (beecast-livechange on the element); getState().live reports it.
The core half is exposed on BeeCastVT for embedders that need it without a mounted player.
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 and headless controller self-test 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, the pacing map, controller
state transitions, subscribe/unsubscribe, setSpeed, seek, and marker normalization.
Structural tests gate the bundle properties every self-contained embedding depends on —
no </script, no workers, no CSS fetches, no third-party license marker, and a stable
public API surface.
License
MIT (text in LICENSE, shipped with the crate) — the component, like the rest
of beecast, is all first-party code.