# `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](https://github.com/dkorolev/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
| `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.
```rust
// Inline both constants whole; the page stays fully self-contained.
let js = beecast_player::PLAYER_JS;
let css = beecast_player::PLAYER_CSS;
```
## Public API
```js
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 `1×` 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`](LICENSE), shipped with the crate) — the component, like the rest
of beecast, is all first-party code.