pub const PLAYER_JS: &str = "// beecast-player: the portable, DOM-free core (see the crate README).\n//\n// Clean-room implementation against public documentation only: the asciicast v1/v2/v3\n// format descriptions and ECMA-48 / xterm control-sequence references. MIT, like the\n// rest of beecast.\n//\n// The exports (attached to `BeeCastVT`, plus CommonJS for the Node self-test):\n// parseCast(text) -> { cols, rows, events, duration, \u{2026} } (times in recording seconds)\n// appendCast(cast, text) -> grow a parsed cast with newly produced lines (live-follow)\n// buildPacing / extendPacing / mapTime -> the recording-time \u{2194} paced-time map\n// new Term(cols, rows) -> .write(text), .resize(c, r), .snapshot()\n\'use strict\';\n(function (root) {\n\n// ---- asciicast parsing ----------------------------------------------------------------\n// v1: one JSON document: {version:1, width, height, stdout:[[delay, text], \u{2026}]} \u{2014} delays\n// are intervals between events.\n// v2: NDJSON: {version:2, width, height} header, then [absolute_seconds, \"o\"|\u{2026}, data].\n// v3: NDJSON: {version:3, term:{cols, rows}} header, `#` comment lines allowed, then\n// [interval_seconds, type, data] \u{2014} \"o\" output, \"r\" resize (\"COLSxROWS\"), \"m\" marker.\nfunction parseCast(text) {\n const src = String(text || \'\');\n const trimmed = src.trimStart();\n if (trimmed.startsWith(\'{\') && trimmed.includes(\'\"stdout\"\')) {\n const v1 = tryJson(trimmed);\n if (v1 && Array.isArray(v1.stdout)) {\n let t = 0;\n const events = [];\n for (const pair of v1.stdout) {\n if (!Array.isArray(pair) || pair.length < 2) continue;\n t += Number(pair[0]) || 0;\n events.push({ t: t, type: \'o\', data: String(pair[1]) });\n }\n return { cols: num(v1.width, 80), rows: num(v1.height, 24), events: events, duration: t, version: 1, tail: \'\' };\n }\n }\n let cols = 80, rows = 24, version = 3, abs = 0;\n const events = [];\n // A live producer can hand over a prefix cut mid-line: when the text ends in a partial\n // line that does not parse, hold it back as `tail` so `appendCast` can complete it.\n let body = src, tail = \'\';\n const cut = src.lastIndexOf(\'\\n\');\n const last = src.slice(cut + 1).trim();\n if (last && !tryJson(last)) { body = src.slice(0, cut + 1); tail = src.slice(cut + 1); }\n for (const raw of body.split(\'\\n\')) {\n const line = raw.trim();\n if (!line || line[0] === \'#\') continue;\n if (line[0] === \'{\') {\n const h = tryJson(line);\n if (h) {\n version = num(h.version, 3);\n if (h.term) { cols = num(h.term.cols, cols); rows = num(h.term.rows, rows); }\n cols = num(h.width, cols);\n rows = num(h.height, rows);\n }\n continue;\n }\n if (line[0] !== \'[\') continue;\n const ev = tryJson(line);\n if (!Array.isArray(ev) || ev.length < 2 || typeof ev[0] !== \'number\') continue;\n abs = version >= 3 ? abs + ev[0] : Math.max(abs, ev[0]);\n const type = String(ev[1]);\n if (type !== \'o\' && type !== \'r\' && type !== \'m\') continue; // input/exit don\'t render\n events.push({ t: abs, type: type, data: ev.length > 2 ? String(ev[2]) : \'\' });\n }\n return { cols: cols, rows: rows, events: events, duration: abs, version: version, tail: tail };\n}\nfunction tryJson(s) { try { return JSON.parse(s); } catch (_) { return null; } }\nfunction num(v, dflt) { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.floor(n) : dflt; }\n\n// Live-follow: append newly produced NDJSON lines to a cast returned by `parseCast`.\n// Chunk boundaries are free \u{2014} only complete (newline-terminated) lines are consumed, and\n// a trailing partial line is buffered on `cast.tail` until its remainder arrives. Event\n// times read exactly as at load (v2 absolute, v3 intervals); stray header or `#` comment\n// lines are skipped. A v1 cast is one JSON document with no line to append to, so it\n// never grows. Returns the number of renderable events appended.\nfunction appendCast(cast, text) {\n const buf = (cast.tail || \'\') + String(text == null ? \'\' : text);\n if (cast.version === 1) { cast.tail = \'\'; return 0; }\n const cut = buf.lastIndexOf(\'\\n\');\n if (cut < 0) { cast.tail = buf; return 0; }\n cast.tail = buf.slice(cut + 1);\n let added = 0;\n for (const raw of buf.slice(0, cut).split(\'\\n\')) {\n const line = raw.trim();\n if (!line || line[0] !== \'[\') continue; // headers, comments, and noise all skip\n const ev = tryJson(line);\n if (!Array.isArray(ev) || ev.length < 2 || typeof ev[0] !== \'number\') continue;\n cast.duration = cast.version >= 3 ? cast.duration + ev[0] : Math.max(cast.duration, ev[0]);\n const type = String(ev[1]);\n if (type !== \'o\' && type !== \'r\' && type !== \'m\') continue; // input/exit don\'t render\n cast.events.push({ t: cast.duration, type: type, data: ev.length > 2 ? String(ev[2]) : \'\' });\n added++;\n }\n return added;\n}\n\n// ---- pacing map ------------------------------------------------------------------------\n// Playback runs on a \"paced\" clock where every silent gap longer than `limit` is shortened\n// to exactly `limit`. Both directions of the piecewise-linear map recording-time \u{2194} paced-\n// time derive from the event times: built once at load, extended in place as a live\n// recording grows (`extendPacing` from the first new event after each `appendCast`).\nfunction buildPacing(events, duration, limit) {\n const pacing = { rec: [0], paced: [0], limit: limit == null ? null : limit, pacedDuration: 0 };\n extendPacing(pacing, events, 0, duration);\n return pacing;\n}\nfunction extendPacing(pacing, events, fromIdx, duration) {\n let lastRec = pacing.rec[pacing.rec.length - 1];\n let lastPaced = pacing.paced[pacing.paced.length - 1];\n const push = function (t) {\n if (t <= lastRec) return;\n const gap = t - lastRec;\n lastPaced += pacing.limit != null && gap > pacing.limit ? pacing.limit : gap;\n lastRec = t;\n pacing.rec.push(lastRec);\n pacing.paced.push(lastPaced);\n };\n for (let i = fromIdx; i < events.length; i++) push(events[i].t);\n push(duration);\n pacing.pacedDuration = lastPaced;\n}\nfunction mapTime(from, to, t) {\n if (t <= 0) return 0;\n let lo = 0, hi = from.length - 1;\n if (t >= from[hi]) return to[hi] + (t - from[hi]);\n while (lo + 1 < hi) { const mid = (lo + hi) >> 1; if (from[mid] <= t) lo = mid; else hi = mid; }\n const span = from[hi] - from[lo];\n const frac = span > 0 ? (t - from[lo]) / span : 0;\n return to[lo] + frac * (to[hi] - to[lo]);\n}\n\n// ---- terminal emulator ----------------------------------------------------------------\n// Cell colors: null = default, 0..255 = indexed, \'#rrggbb\' = truecolor.\n// Attribute bits on each cell:\nconst A_BOLD = 1, A_DIM = 2, A_ITALIC = 4, A_UNDER = 8, A_INVERSE = 16, A_STRIKE = 32;\n\n// DEC special graphics (ESC ( 0): the line-drawing set tmux borders use.\nconst DEC_GRAPHICS = {\n \'j\': \'\u{2518}\', \'k\': \'\u{2510}\', \'l\': \'\u{250c}\', \'m\': \'\u{2514}\', \'n\': \'\u{253c}\', \'q\': \'\u{2500}\', \'t\': \'\u{251c}\', \'u\': \'\u{2524}\',\n \'v\': \'\u{2534}\', \'w\': \'\u{252c}\', \'x\': \'\u{2502}\', \'a\': \'\u{2592}\', \'`\': \'\u{25c6}\', \'f\': \'\u{b0}\', \'g\': \'\u{b1}\', \'o\': \'\u{23ba}\',\n \'p\': \'\u{23bb}\', \'r\': \'\u{23bc}\', \'s\': \'\u{23bd}\', \'~\': \'\u{b7}\', \'y\': \'\u{2264}\', \'z\': \'\u{2265}\', \'{\': \'\u{3c0}\', \'|\': \'\u{2260}\',\n \'}\': \'\u{a3}\', \'.\': \'\u{25bc}\', \',\': \'\u{2190}\', \'+\': \'\u{2192}\', \'-\': \'\u{2191}\', \'0\': \'\u{2588}\', \'h\': \'\u{2592}\', \'i\': \'\u{240b}\',\n};\n\nfunction blankCell() { return { ch: \' \', fg: null, bg: null, attrs: 0 }; }\nfunction blankRow(cols) { const r = new Array(cols); for (let x = 0; x < cols; x++) r[x] = blankCell(); return r; }\n\n// Parser states for the escape-sequence state machine.\nconst GROUND = 0, ESC = 1, CSI = 2, OSC = 3, CHARSET = 4, ESC_IGNORE_ONE = 5;\n\nfunction Term(cols, rows) {\n this.cols = Math.max(1, cols | 0);\n this.rows = Math.max(1, rows | 0);\n this.reset();\n}\n\nTerm.prototype.reset = function () {\n this.screen = [];\n for (let y = 0; y < this.rows; y++) this.screen.push(blankRow(this.cols));\n this.altScreen = null; // saved primary screen while the alternate is active\n this.x = 0;\n this.y = 0;\n this.pen = { fg: null, bg: null, attrs: 0 };\n this.cursorVisible = true;\n this.wrapPending = false; // deferred wrap: set after printing in the last column\n this.autowrap = true;\n this.scrollTop = 0; // inclusive\n this.scrollBottom = this.rows - 1; // inclusive\n this.savedCursor = null; // DECSC: {x, y, pen, charset\u{2026}}\n this.charsets = [\'B\', \'B\']; // G0, G1 (\'B\' = ASCII, \'0\' = DEC graphics)\n this.charsetIdx = 0; // SI selects G0, SO selects G1\n this.state = GROUND;\n this.params = \'\';\n this.oscBuf = \'\';\n this.charsetSlot = 0;\n};\n\nTerm.prototype.resize = function (cols, rows) {\n cols = Math.max(1, cols | 0);\n rows = Math.max(1, rows | 0);\n const next = [];\n for (let y = 0; y < rows; y++) {\n const row = blankRow(cols);\n if (y < this.rows) for (let x = 0; x < Math.min(cols, this.cols); x++) row[x] = this.screen[y][x];\n next.push(row);\n }\n this.screen = next;\n this.cols = cols;\n this.rows = rows;\n this.x = Math.min(this.x, cols - 1);\n this.y = Math.min(this.y, rows - 1);\n this.scrollTop = 0;\n this.scrollBottom = rows - 1;\n this.wrapPending = false;\n if (this.altScreen) this.altScreen = null; // a resize mid-alt is rare; drop the stash\n};\n\n// Feed a chunk of recorded output through the state machine.\nTerm.prototype.write = function (text) {\n const s = String(text);\n for (let i = 0; i < s.length; i++) {\n const ch = s[i];\n switch (this.state) {\n case GROUND: this.ground(ch); break;\n case ESC: this.escState(ch); break;\n case CSI:\n // Parameter/intermediate bytes accumulate; a final byte (0x40\u{2013}0x7E) dispatches.\n if (ch >= \'@\' && ch <= \'~\') { this.csi(ch); this.state = GROUND; }\n else this.params += ch;\n break;\n case OSC:\n // OSC \u{2026} BEL or OSC \u{2026} ESC \\ \u{2014} consumed, never rendered.\n if (ch === \'\\x07\') this.state = GROUND;\n else if (ch === \'\\x1b\') this.state = ESC_IGNORE_ONE;\n else this.oscBuf += ch;\n break;\n case ESC_IGNORE_ONE: this.state = GROUND; break; // the `\\` of ESC \\ (or any ST tail)\n case CHARSET:\n this.charsets[this.charsetSlot] = ch;\n this.state = GROUND;\n break;\n }\n }\n};\n\nTerm.prototype.ground = function (ch) {\n const code = ch.charCodeAt(0);\n if (ch === \'\\x1b\') { this.state = ESC; return; }\n if (ch === \'\\r\') { this.x = 0; this.wrapPending = false; return; }\n if (ch === \'\\n\' || ch === \'\\x0b\' || ch === \'\\x0c\') { this.lineFeed(); return; }\n if (ch === \'\\x08\') { if (this.x > 0) this.x--; this.wrapPending = false; return; }\n if (ch === \'\\t\') { this.x = Math.min(this.cols - 1, (Math.floor(this.x / 8) + 1) * 8); this.wrapPending = false; return; }\n if (ch === \'\\x0e\') { this.charsetIdx = 1; return; } // SO \u{2192} G1\n if (ch === \'\\x0f\') { this.charsetIdx = 0; return; } // SI \u{2192} G0\n if (code < 0x20 || code === 0x7f) return; // other C0 controls + DEL: ignore\n this.print(ch);\n};\n\nTerm.prototype.print = function (ch) {\n if (this.charsets[this.charsetIdx] === \'0\' && DEC_GRAPHICS[ch]) ch = DEC_GRAPHICS[ch];\n if (this.wrapPending) {\n if (this.autowrap) { this.x = 0; this.lineFeed(); }\n this.wrapPending = false;\n }\n const cell = this.screen[this.y][this.x];\n cell.ch = ch;\n cell.fg = this.pen.fg;\n cell.bg = this.pen.bg;\n cell.attrs = this.pen.attrs;\n if (this.x === this.cols - 1) this.wrapPending = true;\n else this.x++;\n};\n\nTerm.prototype.lineFeed = function () {\n this.wrapPending = false;\n if (this.y === this.scrollBottom) this.scrollUp(1);\n else if (this.y < this.rows - 1) this.y++;\n};\n\nTerm.prototype.scrollUp = function (n) {\n for (let k = 0; k < n; k++) {\n this.screen.splice(this.scrollTop, 1);\n this.screen.splice(this.scrollBottom, 0, blankRow(this.cols));\n }\n};\n\nTerm.prototype.scrollDown = function (n) {\n for (let k = 0; k < n; k++) {\n this.screen.splice(this.scrollBottom, 1);\n this.screen.splice(this.scrollTop, 0, blankRow(this.cols));\n }\n};\n\nTerm.prototype.escState = function (ch) {\n switch (ch) {\n case \'[\': this.state = CSI; this.params = \'\'; return;\n case \']\': this.state = OSC; this.oscBuf = \'\'; return;\n case \'(\': this.state = CHARSET; this.charsetSlot = 0; return;\n case \')\': this.state = CHARSET; this.charsetSlot = 1; return;\n case \'D\': this.lineFeed(); break; // IND\n case \'E\': this.x = 0; this.lineFeed(); break; // NEL\n case \'M\': // RI (reverse index)\n this.wrapPending = false;\n if (this.y === this.scrollTop) this.scrollDown(1);\n else if (this.y > 0) this.y--;\n break;\n case \'7\': this.saveCursor(); break; // DECSC\n case \'8\': this.restoreCursor(); break; // DECRC\n case \'c\': { const c = this.cols, r = this.rows; this.reset(); this.cols = c; this.rows = r; // RIS\n this.screen = []; for (let y = 0; y < r; y++) this.screen.push(blankRow(c));\n this.scrollBottom = r - 1; break; }\n case \'P\': case \'X\': case \'^\': case \'_\': this.state = OSC; this.oscBuf = \'\'; return; // DCS/SOS/PM/APC: consume to ST\n case \'=\': case \'>\': break; // keypad modes: ignore\n default: break; // unknown ESC final: ignore\n }\n if (this.state === ESC) this.state = GROUND;\n};\n\nTerm.prototype.saveCursor = function () {\n this.savedCursor = {\n x: this.x, y: this.y,\n pen: { fg: this.pen.fg, bg: this.pen.bg, attrs: this.pen.attrs },\n charsets: this.charsets.slice(), charsetIdx: this.charsetIdx,\n };\n};\n\nTerm.prototype.restoreCursor = function () {\n const s = this.savedCursor;\n if (!s) { this.x = 0; this.y = 0; return; }\n this.x = Math.min(s.x, this.cols - 1);\n this.y = Math.min(s.y, this.rows - 1);\n this.pen = { fg: s.pen.fg, bg: s.pen.bg, attrs: s.pen.attrs };\n this.charsets = s.charsets.slice();\n this.charsetIdx = s.charsetIdx;\n this.wrapPending = false;\n};\n\nTerm.prototype.csi = function (final) {\n const priv = this.params.startsWith(\'?\');\n const raw = priv ? this.params.slice(1) : this.params;\n // Colon sub-parameters (SGR 38:2:\u{2026}) read the same as semicolons for our subset.\n const parts = raw.replace(/:/g, \';\').split(\';\');\n const p = [];\n for (const part of parts) p.push(part === \'\' ? 0 : parseInt(part, 10) || 0);\n const n = Math.max(1, p[0] || 0);\n this.wrapPending = false;\n if (priv) { this.decMode(final, p); return; }\n switch (final) {\n case \'A\': this.y = Math.max(this.scrollRegionTopFor(this.y), this.y - n); break; // CUU\n case \'B\': case \'e\': this.y = Math.min(this.scrollRegionBottomFor(this.y), this.y + n); break; // CUD/VPR\n case \'C\': case \'a\': this.x = Math.min(this.cols - 1, this.x + n); break; // CUF/HPR\n case \'D\': this.x = Math.max(0, this.x - n); break; // CUB\n case \'E\': this.x = 0; this.y = Math.min(this.rows - 1, this.y + n); break; // CNL\n case \'F\': this.x = 0; this.y = Math.max(0, this.y - n); break; // CPL\n case \'G\': case \'`\': this.x = clamp((p[0] || 1) - 1, 0, this.cols - 1); break; // CHA/HPA\n case \'d\': this.y = clamp((p[0] || 1) - 1, 0, this.rows - 1); break; // VPA\n case \'H\': case \'f\': // CUP/HVP\n this.y = clamp((p[0] || 1) - 1, 0, this.rows - 1);\n this.x = clamp((p[1] || 1) - 1, 0, this.cols - 1);\n break;\n case \'J\': this.eraseDisplay(p[0] || 0); break; // ED\n case \'K\': this.eraseLine(p[0] || 0); break; // EL\n case \'L\': if (this.inScrollRegion()) this.insertLines(n); break; // IL\n case \'M\': if (this.inScrollRegion()) this.deleteLines(n); break; // DL\n case \'P\': this.deleteChars(n); break; // DCH\n case \'@\': this.insertChars(n); break; // ICH\n case \'X\': this.eraseChars(n); break; // ECH\n case \'S\': this.scrollUp(n); break; // SU\n case \'T\': this.scrollDown(n); break; // SD\n case \'r\': // DECSTBM\n this.scrollTop = clamp((p[0] || 1) - 1, 0, this.rows - 1);\n this.scrollBottom = clamp((p[1] || this.rows) - 1, this.scrollTop, this.rows - 1);\n this.x = 0; this.y = this.scrollTop;\n break;\n case \'m\': this.sgr(p); break;\n case \'s\': this.saveCursor(); break;\n case \'u\': this.restoreCursor(); break;\n case \'h\': case \'l\': case \'n\': case \'t\': case \'c\': case \'g\': case \'q\': break; // consumed, ignored\n default: break;\n }\n};\n\nTerm.prototype.inScrollRegion = function () { return this.y >= this.scrollTop && this.y <= this.scrollBottom; };\nTerm.prototype.scrollRegionTopFor = function (y) { return y >= this.scrollTop ? this.scrollTop : 0; };\nTerm.prototype.scrollRegionBottomFor = function (y) { return y <= this.scrollBottom ? this.scrollBottom : this.rows - 1; };\n\nTerm.prototype.decMode = function (final, p) {\n const set = final === \'h\';\n for (const mode of p) {\n switch (mode) {\n case 25: this.cursorVisible = set; break;\n case 7: this.autowrap = set; break;\n case 47: case 1047: this.switchAltScreen(set, false); break;\n case 1049: this.switchAltScreen(set, true); break;\n case 1048: if (set) this.saveCursor(); else this.restoreCursor(); break;\n default: break; // mouse/bracketed-paste/etc: playback has no input, ignore\n }\n }\n};\n\nTerm.prototype.switchAltScreen = function (on, withCursor) {\n if (on && !this.altScreen) {\n if (withCursor) this.saveCursor();\n this.altScreen = this.screen;\n this.screen = [];\n for (let y = 0; y < this.rows; y++) this.screen.push(blankRow(this.cols));\n } else if (!on && this.altScreen) {\n this.screen = this.altScreen;\n this.altScreen = null;\n if (withCursor) this.restoreCursor();\n }\n};\n\nTerm.prototype.eraseDisplay = function (mode) {\n if (mode === 2 || mode === 3) {\n for (let y = 0; y < this.rows; y++) this.clearRowRange(y, 0, this.cols);\n } else if (mode === 1) {\n for (let y = 0; y < this.y; y++) this.clearRowRange(y, 0, this.cols);\n this.clearRowRange(this.y, 0, this.x + 1);\n } else {\n this.clearRowRange(this.y, this.x, this.cols);\n for (let y = this.y + 1; y < this.rows; y++) this.clearRowRange(y, 0, this.cols);\n }\n};\n\nTerm.prototype.eraseLine = function (mode) {\n if (mode === 2) this.clearRowRange(this.y, 0, this.cols);\n else if (mode === 1) this.clearRowRange(this.y, 0, this.x + 1);\n else this.clearRowRange(this.y, this.x, this.cols);\n};\n\n// Erased cells keep the pen\'s background (BCE) \u{2014} TUIs rely on it for colored panes.\nTerm.prototype.clearRowRange = function (y, from, to) {\n const row = this.screen[y];\n for (let x = from; x < Math.min(to, this.cols); x++) {\n row[x] = { ch: \' \', fg: null, bg: this.pen.bg, attrs: 0 };\n }\n};\n\nTerm.prototype.insertLines = function (n) {\n for (let k = 0; k < n && this.y <= this.scrollBottom; k++) {\n this.screen.splice(this.scrollBottom, 1);\n this.screen.splice(this.y, 0, blankRow(this.cols));\n }\n};\n\nTerm.prototype.deleteLines = function (n) {\n for (let k = 0; k < n && this.y <= this.scrollBottom; k++) {\n this.screen.splice(this.y, 1);\n this.screen.splice(this.scrollBottom, 0, blankRow(this.cols));\n }\n};\n\nTerm.prototype.deleteChars = function (n) {\n const row = this.screen[this.y];\n row.splice(this.x, Math.min(n, this.cols - this.x));\n while (row.length < this.cols) row.push({ ch: \' \', fg: null, bg: this.pen.bg, attrs: 0 });\n};\n\nTerm.prototype.insertChars = function (n) {\n const row = this.screen[this.y];\n for (let k = 0; k < n; k++) row.splice(this.x, 0, { ch: \' \', fg: null, bg: this.pen.bg, attrs: 0 });\n row.length = this.cols;\n};\n\nTerm.prototype.eraseChars = function (n) {\n this.clearRowRange(this.y, this.x, this.x + n);\n};\n\nTerm.prototype.sgr = function (p) {\n if (p.length === 0) p = [0];\n for (let i = 0; i < p.length; i++) {\n const v = p[i];\n if (v === 0) this.pen = { fg: null, bg: null, attrs: 0 };\n else if (v === 1) this.pen.attrs |= A_BOLD;\n else if (v === 2) this.pen.attrs |= A_DIM;\n else if (v === 3) this.pen.attrs |= A_ITALIC;\n else if (v === 4) this.pen.attrs |= A_UNDER;\n else if (v === 7) this.pen.attrs |= A_INVERSE;\n else if (v === 9) this.pen.attrs |= A_STRIKE;\n else if (v === 21 || v === 22) this.pen.attrs &= ~(A_BOLD | A_DIM);\n else if (v === 23) this.pen.attrs &= ~A_ITALIC;\n else if (v === 24) this.pen.attrs &= ~A_UNDER;\n else if (v === 27) this.pen.attrs &= ~A_INVERSE;\n else if (v === 29) this.pen.attrs &= ~A_STRIKE;\n else if (v >= 30 && v <= 37) this.pen.fg = v - 30;\n else if (v >= 90 && v <= 97) this.pen.fg = v - 90 + 8;\n else if (v === 39) this.pen.fg = null;\n else if (v >= 40 && v <= 47) this.pen.bg = v - 40;\n else if (v >= 100 && v <= 107) this.pen.bg = v - 100 + 8;\n else if (v === 49) this.pen.bg = null;\n else if (v === 38 || v === 48) {\n const isFg = v === 38;\n if (p[i + 1] === 5) {\n const idx = clamp(p[i + 2] || 0, 0, 255);\n if (isFg) this.pen.fg = idx; else this.pen.bg = idx;\n i += 2;\n } else if (p[i + 1] === 2) {\n const hex = \'#\' + hex2(p[i + 2]) + hex2(p[i + 3]) + hex2(p[i + 4]);\n if (isFg) this.pen.fg = hex; else this.pen.bg = hex;\n i += 4;\n }\n }\n }\n};\n\n// The visible screen as rows of style-merged runs, plus the cursor \u{2014} everything a\n// renderer needs, nothing tied to any renderer.\nTerm.prototype.snapshot = function () {\n const rows = [];\n for (let y = 0; y < this.rows; y++) {\n const runs = [];\n let cur = null;\n for (let x = 0; x < this.cols; x++) {\n const c = this.screen[y][x];\n if (cur && cur.fg === c.fg && cur.bg === c.bg && cur.attrs === c.attrs) cur.text += c.ch;\n else {\n cur = { text: c.ch, fg: c.fg, bg: c.bg, attrs: c.attrs };\n runs.push(cur);\n }\n }\n rows.push(runs);\n }\n return {\n cols: this.cols,\n rows: rows,\n cursor: { x: Math.min(this.x, this.cols - 1), y: this.y, visible: this.cursorVisible },\n };\n};\n\n// A plain-text dump (no styling), one string per row \u{2014} for tests and transcripts.\nTerm.prototype.textLines = function () {\n const out = [];\n for (let y = 0; y < this.rows; y++) {\n let line = \'\';\n for (let x = 0; x < this.cols; x++) line += this.screen[y][x].ch;\n out.push(line.replace(/\\s+$/, \'\'));\n }\n return out;\n};\n\nfunction clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }\nfunction hex2(v) { return (clamp(v || 0, 0, 255)).toString(16).padStart(2, \'0\'); }\n\n// The xterm 256-color palette for indexed cells \u{2265} 16 (0\u{2013}15 come from CSS variables so\n// the embedding page can theme them; see player.js/player.css).\nfunction color256(idx) {\n if (idx < 16) return null; // themed via CSS\n if (idx >= 232) { const g = 8 + (idx - 232) * 10; return \'#\' + hex2(g) + hex2(g) + hex2(g); }\n const v = idx - 16;\n const lv = [0, 95, 135, 175, 215, 255];\n return \'#\' + hex2(lv[Math.floor(v / 36)]) + hex2(lv[Math.floor(v / 6) % 6]) + hex2(lv[v % 6]);\n}\n\nconst api = {\n parseCast: parseCast,\n appendCast: appendCast,\n buildPacing: buildPacing,\n extendPacing: extendPacing,\n mapTime: mapTime,\n Term: Term,\n color256: color256,\n A_BOLD: A_BOLD, A_DIM: A_DIM, A_ITALIC: A_ITALIC, A_UNDER: A_UNDER, A_INVERSE: A_INVERSE, A_STRIKE: A_STRIKE,\n};\nroot.BeeCastVT = api;\nif (typeof module !== \'undefined\' && module.exports) module.exports = api;\n\n})(typeof window !== \'undefined\' ? window : globalThis);\n\n// beecast-player: the DOM half (see the crate README). Renders BeeCastVT snapshots,\n// drives the playback clock, and exposes the public BeeCastPlayer API.\n//\n// Clean-room implementation, MIT like the rest of beecast. The time axis is ALWAYS\n// recording time: idle compression (idleTimeLimit) only changes pacing, never the clock\n// the API speaks. The pacing map itself lives in the DOM-free core (vt.js).\n\'use strict\';\n(function (root) {\n\nconst VT = root.BeeCastVT;\nconst SEEK_STEP_SECS = 5;\nconst SPEEDS = [0.5, 1, 1.5, 2, 3, 5];\n\n// ---- rendering -------------------------------------------------------------------------\nconst ATTR_CLASSES = [\n [VT.A_BOLD, \'sp-b\'], [VT.A_DIM, \'sp-d\'], [VT.A_ITALIC, \'sp-i\'],\n [VT.A_UNDER, \'sp-u\'], [VT.A_STRIKE, \'sp-s\'],\n];\n\nfunction colorCss(c, bold) {\n if (c == null) return null;\n if (typeof c === \'string\') return c; // \'#rrggbb\'\n // Bold brightens the 8 base colors \u{2014} the classic terminal behavior TUIs count on.\n const idx = bold && c < 8 ? c + 8 : c;\n return idx < 16 ? \'var(--sp-c\' + idx + \')\' : VT.color256(idx);\n}\n\nfunction esc(s) {\n return s.replace(/&/g, \'&\').replace(/</g, \'<\').replace(/>/g, \'>\');\n}\n\nfunction runHtml(run, hasCursor, cursorCol) {\n // The cursor splits its run into up-to-three spans so only one cell inverts.\n if (hasCursor && run.text.length > 1) {\n const before = { text: run.text.slice(0, cursorCol), fg: run.fg, bg: run.bg, attrs: run.attrs };\n const at = { text: run.text[cursorCol] || \' \', fg: run.fg, bg: run.bg, attrs: run.attrs };\n const after = { text: run.text.slice(cursorCol + 1), fg: run.fg, bg: run.bg, attrs: run.attrs };\n return (before.text ? runHtml(before, false, 0) : \'\') + runHtml(at, true, 0) +\n (after.text ? runHtml(after, false, 0) : \'\');\n }\n const inverse = (run.attrs & VT.A_INVERSE) !== 0;\n const bold = (run.attrs & VT.A_BOLD) !== 0;\n let fg = colorCss(run.fg, bold);\n let bg = colorCss(run.bg, false);\n if (inverse) { const t = fg || \'var(--sp-fg)\'; fg = bg || \'var(--sp-bg)\'; bg = t; }\n const classes = [];\n for (const pair of ATTR_CLASSES) if (run.attrs & pair[0]) classes.push(pair[1]);\n if (hasCursor) classes.push(\'sp-cur\');\n let style = \'\';\n if (fg) style += \'color:\' + fg + \';\';\n if (bg) style += \'background:\' + bg + \';\';\n if (!classes.length && !style) return esc(run.text);\n return \'<span\' + (classes.length ? \' class=\"\' + classes.join(\' \') + \'\"\' : \'\') +\n (style ? \' style=\"\' + style + \'\"\' : \'\') + \'>\' + esc(run.text) + \'</span>\';\n}\n\nfunction screenHtml(snap) {\n const lines = [];\n for (let y = 0; y < snap.rows.length; y++) {\n let x = 0, html = \'\';\n for (const run of snap.rows[y]) {\n const cursorHere = snap.cursor.visible && snap.cursor.y === y &&\n snap.cursor.x >= x && snap.cursor.x < x + run.text.length;\n html += runHtml(run, cursorHere, snap.cursor.x - x);\n x += run.text.length;\n }\n lines.push(html);\n }\n return lines.join(\'\\n\');\n}\n\nfunction fmtClock(secs) {\n secs = Math.max(0, Math.floor(secs));\n const m = Math.floor(secs / 60), s = secs % 60;\n return m + \':\' + String(s).padStart(2, \'0\');\n}\n\nfunction parseTime(v) {\n if (typeof v === \'number\' && isFinite(v)) return v;\n const m = /^(\\d+):(\\d{1,2})$/.exec(String(v || \'\').trim());\n if (m) return Number(m[1]) * 60 + Number(m[2]);\n const n = parseFloat(v);\n return isFinite(n) ? n : 0;\n}\n\n// ---- player ----------------------------------------------------------------------------\nfunction Player(src, mount, opts) {\n opts = opts || {};\n const cast = VT.parseCast(src && src.data);\n this.cast = cast;\n this.term = new VT.Term(cast.cols, cast.rows);\n this.pacing = VT.buildPacing(cast.events, cast.duration, opts.idleTimeLimit);\n // Chapter ticks come from BOTH the embedder (opts.markers) and the recording\'s own\n // in-band \"m\" events \u{2014} including ones that arrive later through append(), so the seek\n // bar shows the same chapters live as it would after a reload.\n this.markers = (opts.markers || []).map(function (m) { return { t: Number(m[0]) || 0, label: String(m[1] || \'\') }; });\n this.absorbMarkers(0);\n this.speed = SPEEDS.indexOf(Number(opts.speed)) >= 0 ? Number(opts.speed) : 1;\n this.playing = false;\n this.pacedPos = 0; // the playback clock, in paced seconds\n this.eventIdx = 0; // events [0, eventIdx) are applied to the term\n this.raf = null;\n this.lastTick = null;\n this.disposed = false;\n this.buildDom(mount, opts.controls !== false);\n if (this.speedBtn) this.speedBtn.textContent = String(this.speed).replace(/\\.0$/, \'\') + \'\\u00d7\';\n this.fit = opts.fit || null;\n this.applyEventsUpTo(0);\n if (opts.startAt != null) this.seek(parseTime(opts.startAt));\n this.render();\n this.layout();\n if (opts.autoPlay) this.play();\n const self = this;\n if (typeof ResizeObserver !== \'undefined\') {\n this.resizeObs = new ResizeObserver(function () { self.layout(); });\n this.resizeObs.observe(this.root.parentNode || this.root);\n }\n}\n\nPlayer.prototype.buildDom = function (mount, controls) {\n const self = this;\n const root = document.createElement(\'div\');\n root.className = \'beecast-player\';\n root.tabIndex = 0;\n root.innerHTML =\n \'<div class=\"sp-screen-box\"><pre class=\"sp-screen\"></pre></div>\' +\n (controls\n ? \'<div class=\"sp-bar\">\' +\n \'<button class=\"sp-play\" type=\"button\" title=\"play/pause (space)\">\u{25b6}</button>\' +\n \'<span class=\"sp-time\">0:00</span>\' +\n \'<div class=\"sp-seek\"><div class=\"sp-fill\"></div><div class=\"sp-markers\"></div></div>\' +\n \'<span class=\"sp-dur\">0:00</span>\' +\n \'<button class=\"sp-speed\" type=\"button\" title=\"speed (< / >)\">1\u{d7}</button>\' +\n \'</div>\'\n : \'\');\n mount.appendChild(root);\n this.root = root;\n this.screenEl = root.querySelector(\'.sp-screen\');\n this.playBtn = root.querySelector(\'.sp-play\');\n this.timeEl = root.querySelector(\'.sp-time\');\n this.durEl = root.querySelector(\'.sp-dur\');\n this.seekEl = root.querySelector(\'.sp-seek\');\n this.fillEl = root.querySelector(\'.sp-fill\');\n this.speedBtn = root.querySelector(\'.sp-speed\');\n if (this.durEl) this.durEl.textContent = fmtClock(this.cast.duration);\n if (this.playBtn) this.playBtn.addEventListener(\'click\', function () { self.toggle(); });\n if (this.speedBtn) this.speedBtn.addEventListener(\'click\', function () { self.cycleSpeed(1); });\n if (this.seekEl) {\n const seekTo = function (ev) {\n const r = self.seekEl.getBoundingClientRect();\n const frac = Math.min(1, Math.max(0, (ev.clientX - r.left) / (r.width || 1)));\n self.seek(frac * self.cast.duration);\n };\n this.seekEl.addEventListener(\'mousedown\', function (ev) {\n seekTo(ev);\n const move = function (e) { seekTo(e); };\n const up = function () { document.removeEventListener(\'mousemove\', move); document.removeEventListener(\'mouseup\', up); };\n document.addEventListener(\'mousemove\', move);\n document.addEventListener(\'mouseup\', up);\n });\n this.marksEl = root.querySelector(\'.sp-markers\');\n this.layoutMarkers();\n }\n this.keyHandler = function (ev) { self.onKey(ev); };\n root.addEventListener(\'keydown\', this.keyHandler);\n root.addEventListener(\'click\', function () { try { root.focus({ preventScroll: true }); } catch (_) { root.focus(); } });\n};\n\n// Fold the recording\'s in-band \"m\" (marker) events from cast.events[fromIdx..] into\n// this.markers, kept sorted (jumpMarker walks them in time order; opts.markers may sit\n// anywhere relative to in-band ones).\nPlayer.prototype.absorbMarkers = function (fromIdx) {\n let grew = false;\n for (let i = fromIdx; i < this.cast.events.length; i++) {\n const ev = this.cast.events[i];\n if (ev.type === \'m\') { this.markers.push({ t: ev.t, label: ev.data }); grew = true; }\n }\n if (grew) this.markers.sort(function (a, b) { return a.t - b.t; });\n};\n\n// (Re)place the chapter ticks: their positions are percentages of the duration, so a\n// growing live recording shifts them left as it lengthens.\nPlayer.prototype.layoutMarkers = function () {\n if (!this.marksEl) return;\n this.marksEl.innerHTML = \'\';\n if (!(this.cast.duration > 0)) return;\n for (const m of this.markers) {\n const tick = document.createElement(\'div\');\n tick.className = \'sp-marker\';\n tick.style.left = Math.min(100, (m.t / this.cast.duration) * 100) + \'%\';\n tick.title = fmtClock(m.t) + (m.label ? \' \' + m.label : \'\');\n this.marksEl.appendChild(tick);\n }\n};\n\nPlayer.prototype.onKey = function (ev) {\n if (ev.metaKey || ev.ctrlKey || ev.altKey) return;\n const k = ev.key;\n if (k === \' \') this.toggle();\n else if (k === \'ArrowLeft\') this.seek(this.getCurrentTime() - SEEK_STEP_SECS);\n else if (k === \'ArrowRight\') this.seek(this.getCurrentTime() + SEEK_STEP_SECS);\n else if (k === \'<\' || k === \',\') this.cycleSpeed(-1);\n else if (k === \'>\' || k === \'.\') this.cycleSpeed(1);\n else if (k === \'[\') this.jumpMarker(-1);\n else if (k === \']\') this.jumpMarker(1);\n else return;\n ev.preventDefault();\n ev.stopPropagation();\n};\n\nPlayer.prototype.jumpMarker = function (dir) {\n if (!this.markers.length) return;\n const now = this.getCurrentTime();\n let target = null;\n if (dir > 0) {\n for (const m of this.markers) if (m.t > now + 0.25) { target = m.t; break; }\n } else {\n for (const m of this.markers) if (m.t < now - 0.25) target = m.t;\n if (target == null) target = 0;\n }\n if (target != null) { this.seek(target); this.play(); }\n};\n\nPlayer.prototype.cycleSpeed = function (dir) {\n const i = SPEEDS.indexOf(this.speed);\n const next = SPEEDS[Math.min(SPEEDS.length - 1, Math.max(0, (i < 0 ? 1 : i) + dir))];\n this.speed = next;\n if (this.speedBtn) this.speedBtn.textContent = String(next).replace(/\\.0$/, \'\') + \'\u{d7}\';\n};\n\n// Apply events so that exactly those with recording time <= t are in the terminal.\n// Forward from the current position when possible; a backward seek replays from zero\n// (the recording is local text \u{2014} replay is cheap and always exact).\nPlayer.prototype.applyEventsUpTo = function (t) {\n const evs = this.cast.events;\n if (this.eventIdx > 0 && evs[this.eventIdx - 1].t > t) {\n this.term = new VT.Term(this.cast.cols, this.cast.rows);\n this.eventIdx = 0;\n }\n let applied = false;\n while (this.eventIdx < evs.length && evs[this.eventIdx].t <= t) {\n const ev = evs[this.eventIdx++];\n if (ev.type === \'o\') this.term.write(ev.data);\n else if (ev.type === \'r\') {\n const m = /^(\\d+)x(\\d+)$/.exec(ev.data.trim());\n if (m) { this.term.resize(Number(m[1]), Number(m[2])); this.layoutPending = true; }\n }\n applied = true;\n }\n return applied;\n};\n\nPlayer.prototype.render = function () {\n this.screenEl.innerHTML = screenHtml(this.term.snapshot());\n this.renderBar();\n if (this.layoutPending) { this.layoutPending = false; this.layout(); }\n};\n\n// The control bar alone \u{2014} cheap enough for every live append even when the screen is not moving.\nPlayer.prototype.renderBar = function () {\n const t = this.getCurrentTime();\n if (this.timeEl) this.timeEl.textContent = fmtClock(t);\n if (this.fillEl) this.fillEl.style.width = (this.cast.duration > 0 ? Math.min(100, (t / this.cast.duration) * 100) : 0) + \'%\';\n};\n\n// fit: scale the fixed-metric terminal down (never up) to the containing box\'s width \u{2014}\n// and, for fit:\'both\', also to the mount\'s height when the embedding page gives it one\n// (a definite flex/viewport height; a content-sized mount never shrinks the terminal).\nPlayer.prototype.layout = function () {\n if (!this.fit) return;\n const box = this.root.querySelector(\'.sp-screen-box\');\n this.screenEl.style.transform = \'\';\n const rect = this.screenEl.getBoundingClientRect();\n const naturalW = rect.width, naturalH = rect.height;\n if (!(naturalW > 0 && naturalH > 0)) return;\n const availW = box.clientWidth;\n let scale = availW > 0 && naturalW > availW ? availW / naturalW : 1;\n if (this.fit === \'both\' && this.root.parentNode) {\n const bar = this.root.querySelector(\'.sp-bar\');\n const availH = this.root.parentNode.clientHeight - (bar ? bar.offsetHeight : 0);\n // The 2px slack keeps a content-sized mount (whose height IS the terminal\'s) stable.\n if (availH > 40 && naturalH * scale > availH + 2) scale = Math.min(scale, availH / naturalH);\n }\n this.screenEl.style.transform = scale < 1 ? \'scale(\' + scale + \')\' : \'\';\n box.style.height = naturalH * scale + \'px\';\n // Center the (possibly scaled) terminal in the pane; the layout box keeps its unscaled\n // width, so flex centering would be off \u{2014} compute the margin from the DISPLAY width.\n const displayW = naturalW * scale;\n this.screenEl.style.marginLeft = availW > displayW ? (availW - displayW) / 2 + \'px\' : \'\';\n};\n\nPlayer.prototype.tick = function (nowMs) {\n if (this.disposed || !this.playing) return;\n const dt = this.lastTick == null ? 0 : (nowMs - this.lastTick) / 1000;\n this.lastTick = nowMs;\n this.pacedPos = Math.min(this.pacing.pacedDuration, this.pacedPos + dt * this.speed);\n const changed = this.applyEventsUpTo(this.getCurrentTime());\n if (changed || this.timeEl) this.render();\n if (this.pacedPos >= this.pacing.pacedDuration) { this.pause(); return; }\n const self = this;\n this.raf = requestAnimationFrame(function (ts) { self.tick(ts); });\n};\n\nPlayer.prototype.play = function () {\n if (this.disposed || this.playing) return;\n if (this.pacedPos >= this.pacing.pacedDuration) this.pacedPos = 0; // replay from the top\n this.playing = true;\n this.lastTick = null;\n if (this.playBtn) this.playBtn.textContent = \'\u{23f8}\';\n const self = this;\n this.raf = requestAnimationFrame(function (ts) { self.tick(ts); });\n};\n\nPlayer.prototype.pause = function () {\n this.playing = false;\n if (this.raf != null) { cancelAnimationFrame(this.raf); this.raf = null; }\n if (this.playBtn) this.playBtn.textContent = \'\u{25b6}\';\n};\n\nPlayer.prototype.toggle = function () { if (this.playing) this.pause(); else this.play(); };\n\nPlayer.prototype.seek = function (t) {\n t = Math.min(this.cast.duration, Math.max(0, parseTime(t)));\n this.pacedPos = VT.mapTime(this.pacing.rec, this.pacing.paced, t);\n this.applyEventsUpTo(t);\n this.render();\n};\n\nPlayer.prototype.getCurrentTime = function () {\n return VT.mapTime(this.pacing.paced, this.pacing.rec, this.pacedPos);\n};\n\n// Live-follow: feed newly produced cast lines (v2/v3 NDJSON) into a mounted player as the\n// recording grows \u{2014} how the data arrives (WebSocket, polling, a tailed file) is the\n// caller\'s business. Chunk boundaries are free; a partial trailing line is buffered until\n// its remainder arrives. The follow policy is positional, like `tail -f`: a playhead\n// resting at the live edge stays pinned to it and renders each append immediately, while a\n// viewer who paused earlier or seeked back is never yanked forward \u{2014} they just watch the\n// duration grow. A *playing* player keeps its own clock; the longer recording simply no\n// longer auto-pauses it at the old end (and once playback catches the edge and parks,\n// subsequent appends pick it up and follow).\nPlayer.prototype.append = function (text) {\n if (this.disposed) return;\n const atEdge = !this.playing && this.pacedPos >= this.pacing.pacedDuration - 1e-9;\n const fromIdx = this.cast.events.length;\n const prevDuration = this.cast.duration;\n VT.appendCast(this.cast, text);\n if (this.cast.events.length === fromIdx && this.cast.duration === prevDuration) return;\n VT.extendPacing(this.pacing, this.cast.events, fromIdx, this.cast.duration);\n this.absorbMarkers(fromIdx);\n if (this.durEl) this.durEl.textContent = fmtClock(this.cast.duration);\n this.layoutMarkers();\n if (atEdge) {\n this.pacedPos = this.pacing.pacedDuration;\n this.applyEventsUpTo(this.getCurrentTime());\n this.render();\n } else {\n this.renderBar(); // same playhead, longer recording: only the bar\'s proportions move\n }\n};\n\nPlayer.prototype.dispose = function () {\n this.disposed = true;\n this.pause();\n if (this.resizeObs) { try { this.resizeObs.disconnect(); } catch (_) {} this.resizeObs = null; }\n if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root);\n};\n\nroot.BeeCastPlayer = {\n create: function (src, mount, opts) { return new Player(src, mount, opts); },\n};\n\n})(typeof window !== \'undefined\' ? window : globalThis);\n";Expand description
The player bundle: vt.js (asciicast parsing + the VT emulator + the pacing map, all
DOM-free) then player.js (the renderer, playback clock, and controls). Inline it in
one <script> element; it performs no network requests and loads no workers.