Skip to main content

PLAYER_JS

Constant PLAYER_JS 

Source
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, \'&amp;\').replace(/</g, \'&lt;\').replace(/>/g, \'&gt;\');\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  // The fullscreen target: the embedding page may pass a wrapper (its own chrome \u{2014} a\n  // chapters sidebar, a toolbar \u{2014} rides along into fullscreen); default is the player.\n  this.fsEl = opts.fullscreenEl || null;\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  // Entering/leaving fullscreen resizes the root itself, which the parent-watching\n  // ResizeObserver may not notice \u{2014} refit explicitly.\n  this.fsHandler = function () { self.layout(); };\n  document.addEventListener(\'fullscreenchange\', this.fsHandler);\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 (&lt; / &gt;)\">1\u{d7}</button>\' +\n        \'<button class=\"sp-fs\" type=\"button\" title=\"fullscreen (f)\">\u{26f6}</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  this.fsBtn = root.querySelector(\'.sp-fs\');\n  if (this.fsBtn) this.fsBtn.addEventListener(\'click\', function () { self.toggleFullscreen(); });\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 if (k === \'f\' || k === \'F\') this.toggleFullscreen();\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// Toggle fullscreen on the embedder-supplied wrapper (fullscreenEl) or the player itself.\n// Bound to the \u{26f6} bar button and the `f` key; a no-op where the Fullscreen API is absent.\nPlayer.prototype.toggleFullscreen = function () {\n  const el = this.fsEl || this.root;\n  if (document.fullscreenElement === el) {\n    if (document.exitFullscreen) document.exitFullscreen();\n  } else if (el.requestFullscreen) {\n    el.requestFullscreen();\n  }\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    // A fullscreened root answers to the viewport, not to the (now-behind) mount.\n    const holder = document.fullscreenElement === this.root ? this.root : this.root.parentNode;\n    const availH = holder.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.fsHandler) { document.removeEventListener(\'fullscreenchange\', this.fsHandler); this.fsHandler = 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.