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: headless playback controller (see the crate README).\n//\n// Owns cast state, the VT terminal, pacing, the playback clock, markers, and\n// subscribers. No DOM, no CSS. Injectable scheduling makes tests deterministic.\n// Clean-room implementation, MIT like the rest of beecast.\n\'use strict\';\n(function (root) {\n\nconst VT = root.BeeCastVT;\nconst SPEEDS = [0.5, 1, 1.5, 2, 3, 5];\n\n// ---- markers ---------------------------------------------------------------------------\n// Public marker model (Phase 5). Tuples [time, label] remain accepted and are normalized.\nfunction normalizeMarker(raw, source, index) {\n  if (raw == null) return null;\n  if (Array.isArray(raw)) {\n    return {\n      id: \'ext-\' + index + \'-\' + (Number(raw[0]) || 0),\n      time: Number(raw[0]) || 0,\n      type: \'chapter\',\n      label: String(raw[1] || \'\'),\n      source: source || \'sidecar\',\n    };\n  }\n  if (typeof raw === \'object\') {\n    const time = Number(raw.time != null ? raw.time : raw.t) || 0;\n    const id = raw.id != null ? String(raw.id) : (source || \'m\') + \'-\' + index + \'-\' + time;\n    return {\n      id: id,\n      time: time,\n      type: raw.type != null ? String(raw.type) : \'chapter\',\n      label: String(raw.label != null ? raw.label : raw.title || \'\'),\n      description: raw.description != null ? String(raw.description) : undefined,\n      color: raw.color != null ? String(raw.color) : undefined,\n      source: raw.source != null ? String(raw.source) : (source || \'integration\'),\n      data: raw.data,\n    };\n  }\n  return null;\n}\n\nfunction normalizeMarkers(list, source) {\n  const out = [];\n  const arr = list || [];\n  for (let i = 0; i < arr.length; i++) {\n    const m = normalizeMarker(arr[i], source, i);\n    if (m) out.push(m);\n  }\n  return out;\n}\n\nfunction sortMarkers(markers) {\n  markers.sort(function (a, b) {\n    if (a.time !== b.time) return a.time - b.time;\n    // Stable by source priority then id so duplicate timestamps stay deterministic.\n    const sa = a.source || \'\', sb = b.source || \'\';\n    if (sa !== sb) return sa < sb ? -1 : 1;\n    return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;\n  });\n  return markers;\n}\n\n// ---- clock -----------------------------------------------------------------------------\nfunction defaultClock() {\n  return {\n    now: function () {\n      return (typeof performance !== \'undefined\' && performance.now)\n        ? performance.now()\n        : Date.now();\n    },\n    requestAnimationFrame: function (cb) {\n      if (typeof requestAnimationFrame !== \'undefined\') return requestAnimationFrame(cb);\n      return setTimeout(function () { cb(Date.now()); }, 16);\n    },\n    cancelAnimationFrame: function (id) {\n      if (typeof cancelAnimationFrame !== \'undefined\') cancelAnimationFrame(id);\n      else clearTimeout(id);\n    },\n  };\n}\n\n// ---- controller ------------------------------------------------------------------------\nfunction Controller(opts) {\n  opts = opts || {};\n  this.clock = opts.clock || defaultClock();\n  this.speedList = SPEEDS.slice();\n  this.listeners = [];\n  this.disposed = false;\n  this.raf = null;\n  this.lastTick = null;\n  this.emitScheduled = false;\n  this._pendingTimeupdate = null;\n  this._castMarkersFrom = 0;\n\n  const src = resolveSource(opts);\n  const cast = VT.parseCast(src);\n  this.cast = cast;\n  this.term = new VT.Term(cast.cols, cast.rows);\n  this._terminalSnapshot = null;\n  this._terminalSnapshotDirty = true;\n  this.pacing = VT.buildPacing(cast.events, cast.duration, opts.idleTimeLimit);\n  this.idleTimeLimit = opts.idleTimeLimit == null ? null : opts.idleTimeLimit;\n\n  this.externalMarkers = normalizeMarkers(opts.markers, \'sidecar\');\n  this.castMarkers = [];\n  this.markers = [];\n  this.absorbCastMarkers(0);\n\n  this.speed = this.speedList.indexOf(Number(opts.speed)) >= 0 ? Number(opts.speed) : 1;\n  this.status = \'idle\'; // idle | playing | paused | ended\n  this.pacedPos = 0;\n  this.eventIdx = 0;\n  this.atLiveEdge = cast.duration <= 0;\n  this.live = false; // declared-live mode (see setLive), distinct from the positional atLiveEdge\n\n  this.applyEventsUpTo(0);\n  if (opts.startAt != null) this.seek(parseTime(opts.startAt), { origin: \'api\', silent: true });\n  // Initial status after optional startAt.\n  if (this.pacedPos >= this.pacing.pacedDuration && this.pacing.pacedDuration > 0) {\n    this.status = \'ended\';\n  } else if (this.pacedPos > 0) {\n    this.status = \'paused\';\n  }\n  this.syncLiveEdge();\n}\n\nfunction resolveSource(opts) {\n  // Explicit source adapters (Phase 6). The default is inline text; network is never implied.\n  if (opts.source) {\n    const s = opts.source;\n    if (s.type === \'text\') return String(s.data || \'\');\n    if (s.type === \'custom\') return \'\'; // custom sources start empty and append\n    // file / stream need the DOM/fetch layer \u{2014} reject with a stable error code via throw\n    // only when actively used; here treat unknown as empty and let callers use load().\n    if (s.type === \'file\' || s.type === \'stream\') return \'\';\n  }\n  if (opts.data != null) return String(opts.data);\n  return \'\';\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\nController.create = function (opts) {\n  return new Controller(opts);\n};\n\nController.SPEEDS = SPEEDS;\n\nController.prototype.absorbCastMarkers = function (fromIdx) {\n  for (let i = fromIdx; i < this.cast.events.length; i++) {\n    const ev = this.cast.events[i];\n    if (ev.type === \'m\') {\n      this.castMarkers.push({\n        id: \'cast-\' + i + \'-\' + ev.t,\n        time: ev.t,\n        type: \'chapter\',\n        label: String(ev.data || \'\'),\n        source: \'cast\',\n      });\n    }\n  }\n  this.rebuildMarkers();\n};\n\nController.prototype.rebuildMarkers = function () {\n  this.markers = sortMarkers(this.externalMarkers.concat(this.castMarkers));\n};\n\n// Integration markers without mutating cast-sourced ones.\nController.prototype.setMarkers = function (list) {\n  if (this.disposed) return;\n  this.externalMarkers = normalizeMarkers(list, \'integration\');\n  this.rebuildMarkers();\n  this.emit({ type: \'markerchange\', origin: \'api\' });\n};\n\nController.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    this._terminalSnapshotDirty = true;\n  }\n  let applied = false;\n  let resized = 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])); resized = true; }\n    }\n    applied = true;\n  }\n  if (applied || resized) this._terminalSnapshotDirty = true;\n  return { applied: applied, resized: resized };\n};\n\nController.prototype.snapshotTerminal = function () {\n  if (!this._terminalSnapshotDirty && this._terminalSnapshot) return this._terminalSnapshot;\n  const snap = this.term.snapshot();\n  // Defensive copy so subscribers cannot mutate internal term state.\n  const rows = [];\n  for (let y = 0; y < snap.rows.length; y++) {\n    const line = [];\n    for (let i = 0; i < snap.rows[y].length; i++) {\n      const r = snap.rows[y][i];\n      line.push({ text: r.text, fg: r.fg, bg: r.bg, attrs: r.attrs });\n    }\n    rows.push(line);\n  }\n  this._terminalSnapshot = {\n    rows: rows,\n    cursor: {\n      x: snap.cursor.x,\n      y: snap.cursor.y,\n      visible: snap.cursor.visible,\n    },\n  };\n  this._terminalSnapshotDirty = false;\n  return this._terminalSnapshot;\n};\n\nController.prototype.getCurrentTime = function () {\n  return VT.mapTime(this.pacing.paced, this.pacing.rec, this.pacedPos);\n};\n\nController.prototype.syncLiveEdge = function () {\n  const edge = this.pacing.pacedDuration <= 0\n    || this.pacedPos >= this.pacing.pacedDuration - 1e-9;\n  this.atLiveEdge = edge;\n};\n\nController.prototype.getState = function () {\n  const t = this.getCurrentTime();\n  const markers = this.markers.map(function (m) {\n    return {\n      id: m.id,\n      time: m.time,\n      type: m.type,\n      label: m.label,\n      description: m.description,\n      color: m.color,\n      source: m.source,\n      data: m.data,\n    };\n  });\n  return {\n    status: this.status,\n    currentTime: t,\n    duration: this.cast.duration,\n    speed: this.speed,\n    atLiveEdge: this.atLiveEdge,\n    live: this.live,\n    canAppend: this.cast.version !== 1,\n    markers: markers,\n    terminal: this.snapshotTerminal(),\n    dimensions: { columns: this.term.cols, rows: this.term.rows },\n  };\n};\n\n// Live mode: the EMBEDDER declares the recording is still being produced (it knows; the\n// controller only sees text). The playhead parks at the growing edge \u{2014} every append\n// renders immediately \u{2014} and the view pins the seek bar full-width in the live color, so\n// the bar reads \"now\", not a position that jitters as the duration grows. Any explicit\n// rewind \u{2014} a seek before the edge, or play() (which would replay from the top) \u{2014} drops\n// live mode: the viewer chose a position, and the bar must tell the truth again.\nController.prototype.setLive = function (on, origin) {\n  on = !!on;\n  if (this.disposed || this.live === on) return;\n  this.live = on;\n  if (on) {\n    this.pause(origin || \'api\');\n    this.pacedPos = this.pacing.pacedDuration;\n    this.applyEventsUpTo(this.getCurrentTime());\n    this.syncLiveEdge();\n  }\n  this.emit({ type: \'livechange\', origin: origin || \'api\', live: on });\n};\n\nController.prototype.subscribe = function (listener) {\n  if (typeof listener !== \'function\') return function () {};\n  const self = this;\n  if (this.disposed) {\n    // Disposed controllers still deliver one final state snapshot, then never again.\n    try { listener(this.getState(), { type: \'disposed\' }); } catch (_) {}\n    return function () {};\n  }\n  this.listeners.push(listener);\n  try { listener(this.getState(), { type: \'ready\' }); } catch (_) {}\n  let active = true;\n  return function unsubscribe() {\n    if (!active) return;\n    active = false;\n    const i = self.listeners.indexOf(listener);\n    if (i >= 0) self.listeners.splice(i, 1);\n  };\n};\n\nController.prototype.emit = function (meta) {\n  if (this.disposed) return;\n  meta = meta || { type: \'change\' };\n  // Discrete events (play, seek, speedchange, durationchange, \u{2026}) always deliver\n  // immediately: last-write-wins coalescing must never drop them. Only the\n  // high-frequency timeupdate stream coalesces to animation-frame rate, OR-ing\n  // the terminalChanged/resized flags of any skipped frames.\n  if (meta.type !== \'timeupdate\') {\n    this.deliver(meta);\n    return;\n  }\n  const pending = this._pendingTimeupdate;\n  if (pending) {\n    pending.terminalChanged = pending.terminalChanged || meta.terminalChanged;\n    pending.resized = pending.resized || meta.resized;\n  } else {\n    this._pendingTimeupdate = meta;\n  }\n  if (this.emitScheduled) return;\n  this.emitScheduled = true;\n  const self = this;\n  this.clock.requestAnimationFrame(function () {\n    self.emitScheduled = false;\n    const m = self._pendingTimeupdate;\n    self._pendingTimeupdate = null;\n    if (m && !self.disposed) self.deliver(m);\n  });\n};\n\nController.prototype.deliver = function (meta) {\n  if (!this.listeners.length) return;\n  const state = this.getState();\n  const list = this.listeners.slice();\n  for (let i = 0; i < list.length; i++) {\n    try { list[i](state, meta || { type: \'change\' }); } catch (_) {}\n  }\n};\n\nController.prototype.play = function (origin) {\n  if (this.disposed) return;\n  if (this.status === \'playing\') return;\n  // Playing from live mode is a rewind (parked at the edge, play replays from the top).\n  if (this.live) this.setLive(false, origin || \'api\');\n  if (this.pacedPos >= this.pacing.pacedDuration) {\n    this.pacedPos = 0;\n    this.applyEventsUpTo(0);\n  }\n  this.status = \'playing\';\n  this.lastTick = null;\n  this.syncLiveEdge();\n  this.emit({ type: \'play\', origin: origin || \'api\' });\n  const self = this;\n  this.raf = this.clock.requestAnimationFrame(function (ts) { self.tick(ts); });\n};\n\nController.prototype.pause = function (origin) {\n  if (this.disposed) return;\n  if (this.status !== \'playing\') {\n    if (this.status === \'idle\' && this.pacedPos > 0) this.status = \'paused\';\n    return;\n  }\n  this.status = this.pacedPos >= this.pacing.pacedDuration && this.pacing.pacedDuration > 0\n    ? \'ended\'\n    : \'paused\';\n  if (this.raf != null) {\n    this.clock.cancelAnimationFrame(this.raf);\n    this.raf = null;\n  }\n  this.lastTick = null;\n  this.syncLiveEdge();\n  this.emit({ type: this.status === \'ended\' ? \'ended\' : \'pause\', origin: origin || \'api\' });\n};\n\nController.prototype.toggle = function (origin) {\n  if (this.status === \'playing\') this.pause(origin);\n  else this.play(origin);\n};\n\nController.prototype.tick = function (nowMs) {\n  if (this.disposed || this.status !== \'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 result = this.applyEventsUpTo(this.getCurrentTime());\n  this.syncLiveEdge();\n  if (this.pacedPos >= this.pacing.pacedDuration) {\n    this.status = \'ended\';\n    if (this.raf != null) {\n      this.clock.cancelAnimationFrame(this.raf);\n      this.raf = null;\n    }\n    this.emit({ type: \'ended\', origin: \'source\', resized: result.resized });\n    return;\n  }\n  this.emit({\n    type: \'timeupdate\',\n    origin: \'source\',\n    resized: result.resized,\n    terminalChanged: result.applied,\n  });\n  const self = this;\n  this.raf = this.clock.requestAnimationFrame(function (ts) { self.tick(ts); });\n};\n\nController.prototype.seek = function (t, opts) {\n  if (this.disposed) return;\n  opts = opts || {};\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  if (this.status === \'ended\' && t < this.cast.duration) this.status = \'paused\';\n  if (this.status === \'idle\' && t > 0) this.status = \'paused\';\n  if (t <= 0 && this.status !== \'playing\') this.status = \'idle\';\n  this.syncLiveEdge();\n  // Seeking away from the edge is the viewer choosing a position: live mode ends. A seek\n  // TO the edge (e.g. seek(Infinity) while entering live) keeps it.\n  if (this.live && t < this.cast.duration - 0.25) this.setLive(false, opts.origin || \'api\');\n  if (!opts.silent) this.emit({ type: \'seek\', origin: opts.origin || \'api\', time: t });\n};\n\nController.prototype.setSpeed = function (v, origin) {\n  if (this.disposed) return;\n  const n = Number(v);\n  if (this.speedList.indexOf(n) < 0) return;\n  if (this.speed === n) return;\n  this.speed = n;\n  this.emit({ type: \'speedchange\', origin: origin || \'api\', speed: n });\n};\n\nController.prototype.cycleSpeed = function (dir, origin) {\n  const i = this.speedList.indexOf(this.speed);\n  const next = this.speedList[Math.min(this.speedList.length - 1, Math.max(0, (i < 0 ? 1 : i) + dir))];\n  this.setSpeed(next, origin);\n};\n\nController.prototype.jumpMarker = function (dir, origin) {\n  if (!this.markers.length) return;\n  const now = this.getCurrentTime();\n  let target = null;\n  if (dir > 0) {\n    for (let i = 0; i < this.markers.length; i++) {\n      if (this.markers[i].time > now + 0.25) { target = this.markers[i]; break; }\n    }\n  } else {\n    for (let i = 0; i < this.markers.length; i++) {\n      if (this.markers[i].time < now - 0.25) target = this.markers[i];\n    }\n    if (target == null) {\n      this.seek(0, { origin: origin || \'marker\' });\n      return;\n    }\n  }\n  // Seek only \u{2014} leave playing/paused alone. [ ] / chapter jumps must not autoplay.\n  if (target != null) this.seek(target.time, { origin: origin || \'marker\' });\n};\n\n// Live-follow append (v2/v3). Same positional tail -f policy as before.\nController.prototype.append = function (text) {\n  if (this.disposed) return;\n  const wasPlaying = this.status === \'playing\';\n  // Declared-live pins unconditionally; otherwise the positional tail-f rule applies.\n  const atEdge = this.live || (!wasPlaying && 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.absorbCastMarkers(fromIdx);\n  if (this.status === \'ended\' && this.cast.duration > prevDuration) {\n    // Longer recording: ended at the old edge becomes paused-at-edge readiness for follow.\n    if (atEdge) this.status = \'paused\';\n  }\n  if (atEdge) {\n    this.pacedPos = this.pacing.pacedDuration;\n    this.applyEventsUpTo(this.getCurrentTime());\n  }\n  this.syncLiveEdge();\n  this.emit({\n    type: \'durationchange\',\n    origin: \'source\',\n    duration: this.cast.duration,\n    atEdge: atEdge,\n  });\n};\n\n// Load replacement cast text (full reparse). Used by the component\'s load().\nController.prototype.load = function (opts) {\n  if (this.disposed) return;\n  opts = opts || {};\n  const wasPlaying = this.status === \'playing\';\n  if (this.raf != null) {\n    this.clock.cancelAnimationFrame(this.raf);\n    this.raf = null;\n  }\n  const data = opts.data != null ? String(opts.data)\n    : (opts.source && opts.source.type === \'text\' ? String(opts.source.data || \'\') : \'\');\n  this.cast = VT.parseCast(data);\n  this.term = new VT.Term(this.cast.cols, this.cast.rows);\n  this._terminalSnapshot = null;\n  this._terminalSnapshotDirty = true;\n  this.pacing = VT.buildPacing(this.cast.events, this.cast.duration, this.idleTimeLimit);\n  this.castMarkers = [];\n  if (opts.markers) this.externalMarkers = normalizeMarkers(opts.markers, \'sidecar\');\n  this.absorbCastMarkers(0);\n  this.pacedPos = 0;\n  this.eventIdx = 0;\n  this.status = \'idle\';\n  this.applyEventsUpTo(0);\n  if (opts.startAt != null) this.seek(parseTime(opts.startAt), { silent: true });\n  this.syncLiveEdge();\n  this.emit({ type: \'ready\', origin: \'api\' });\n  if (opts.autoPlay || wasPlaying && opts.resume) this.play(\'api\');\n};\n\nController.prototype.dispose = function () {\n  if (this.disposed) return;\n  this.disposed = true;\n  if (this.raf != null) {\n    this.clock.cancelAnimationFrame(this.raf);\n    this.raf = null;\n  }\n  this.listeners = [];\n  this.status = \'idle\';\n};\n\n// Compatibility helpers for the legacy Player view.\nController.prototype.isPlaying = function () {\n  return this.status === \'playing\';\n};\n\nroot.BeeCastController = {\n  create: Controller.create,\n  SPEEDS: SPEEDS,\n  normalizeMarkers: normalizeMarkers,\n  parseTime: parseTime,\n  // Internal constructor for advanced tests.\n  Controller: Controller,\n};\n\n})(typeof window !== \'undefined\' ? window : globalThis);\n\n// beecast-player: the DOM half (see the crate README). Renders controller state,\n// builds the default controls, registers <beecast-player>, and keeps BeeCastPlayer.create\n// as a compatibility adapter over the same surface.\n//\n// Clean-room implementation, MIT like the rest of beecast. The time axis is ALWAYS\n// recording time: idle compression only changes pacing, never the clock the API speaks.\n\'use strict\';\n(function (root) {\n\nconst VT = root.BeeCastVT;\nconst Controller = root.BeeCastController;\nconst SEEK_STEP_SECS = 5;\nconst SPEEDS = Controller.SPEEDS;\n\n// Center play affordance: an inline SVG \"|>\" (bar + chevron, equal height) \u{2014} crisp at any\n// size, no font metrics mismatch between `|` and `>`.\nconst BIG_PLAY =\n  \'<svg class=\"sp-bigplay-icon\" viewBox=\"0 0 80 80\" width=\"1em\" height=\"1em\" aria-hidden=\"true\" focusable=\"false\">\' +\n  \'<rect x=\"10\" y=\"8\" width=\"12\" height=\"64\" rx=\"2\" fill=\"currentColor\"/>\' +\n  \'<path d=\"M32 8 L70 40 L32 72 Z\" fill=\"currentColor\"/>\' +\n  \'</svg>\';\n\nconst ICON_PLAY = \'\u{25b6}\';\nconst ICON_PAUSE = \'\u{23f8}\';\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;\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  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 parseControls(controls) {\n  if (controls === false) {\n    return { play: false, seek: false, time: false, speed: false, chapters: false, fullscreen: false, live: false };\n  }\n  const d = { play: true, seek: true, time: true, speed: true, chapters: true, fullscreen: true, live: false };\n  if (controls && typeof controls === \'object\') {\n    for (const k of Object.keys(d)) {\n      if (controls[k] != null) d[k] = !!controls[k];\n    }\n  }\n  return d;\n}\n\nfunction dispatchBee(el, name, detail) {\n  if (!el || typeof CustomEvent === \'undefined\') return;\n  try {\n    el.dispatchEvent(new CustomEvent(name, {\n      detail: detail || {},\n      bubbles: true,\n      composed: true,\n    }));\n  } catch (_) {}\n}\n\n// ---- player view -----------------------------------------------------------------------\nfunction Player(src, mount, opts) {\n  opts = opts || {};\n  this.opts = opts;\n  this.controlsCfg = parseControls(opts.controls);\n  this.fit = opts.fit || null;\n  this.fsEl = opts.fullscreenEl || null;\n  this.accessibility = opts.accessibility || \'snapshot\';\n  this.disposed = false;\n  this._lastAtEdge = null;\n\n  const data = src && (src.data != null ? src.data : src.cast);\n  this.controller = Controller.create({\n    data: data,\n    source: src && src.source,\n    idleTimeLimit: opts.idleTimeLimit,\n    markers: opts.markers,\n    speed: opts.speed,\n    startAt: opts.startAt,\n    clock: opts.clock,\n  });\n\n  this.buildDom(mount, this.controlsCfg);\n  this.bindController();\n  this.layout();\n  // Compatibility surface: read-only playing getter over controller state.\n  Object.defineProperty(this, \'playing\', {\n    configurable: true,\n    enumerable: true,\n    get: function () { return this.controller.isPlaying(); },\n  });\n  // Non-public fields kept readable during the migration window only.\n  Object.defineProperty(this, \'pacedPos\', {\n    configurable: true,\n    get: function () { return this.controller.pacedPos; },\n  });\n  Object.defineProperty(this, \'eventIdx\', {\n    configurable: true,\n    get: function () { return this.controller.eventIdx; },\n  });\n  Object.defineProperty(this, \'cast\', {\n    configurable: true,\n    get: function () { return this.controller.cast; },\n  });\n  Object.defineProperty(this, \'speed\', {\n    configurable: true,\n    get: function () { return this.controller.speed; },\n    set: function (v) { this.controller.setSpeed(v); },\n  });\n\n  if (opts.autoPlay) this.play();\n  // Declared-live at mount (growing recordings): park at the edge with no play overlay.\n  if (opts.live) this.setLive(true, \'api\');\n  const self = this;\n  if (typeof ResizeObserver !== \'undefined\') {\n    this.resizeObs = new ResizeObserver(function () {\n      if (!self._layouting) self.layout();\n    });\n    this.resizeObs.observe(this.root.parentNode || this.root);\n  }\n  this.fsHandler = function () { self.layout(); };\n  if (typeof document !== \'undefined\') {\n    document.addEventListener(\'fullscreenchange\', this.fsHandler);\n  }\n}\n\nPlayer.prototype.bindController = function () {\n  const self = this;\n  this.unsubscribe = this.controller.subscribe(function (state, meta) {\n    self.onState(state, meta || {});\n  });\n};\n\nPlayer.prototype.onState = function (state, meta) {\n  if (this.disposed) return;\n  const type = meta.type || \'change\';\n\n  if (this.screenEl) {\n    if (type === \'ready\' || type === \'seek\' || type === \'play\' || type === \'pause\' ||\n        type === \'ended\' || type === \'timeupdate\' || type === \'durationchange\' ||\n        type === \'change\' || meta.terminalChanged || meta.resized) {\n      // High-frequency path: still render terminal when it may have changed.\n      if (type !== \'timeupdate\' || meta.terminalChanged || meta.resized || !this._paintedOnce) {\n        this.screenEl.innerHTML = screenHtml(state.terminal);\n        this._paintedOnce = true;\n        if (this.accessibility === \'snapshot\' && this.a11yEl) {\n          this.a11yEl.textContent = terminalPlain(state.terminal);\n        }\n      }\n    }\n  }\n\n  this.renderBar(state);\n  this.syncOverlay(state);\n  this.syncChaptersUi(state);\n  // Declared-live: the bar pins full-width in the live color (see .sp-islive in the CSS).\n  if (this.root) this.root.classList.toggle(\'sp-islive\', !!state.live);\n  if (meta.resized) this.layout();\n  if (type === \'ready\' || type === \'durationchange\' || type === \'markerchange\') {\n    this.layoutMarkers(state);\n  }\n\n  // Integration events on the root element (Phase 2).\n  const el = this.eventTarget || this.root;\n  if (type === \'ready\') dispatchBee(el, \'beecast-ready\', { state: publicState(state) });\n  if (type === \'play\') dispatchBee(el, \'beecast-play\', { origin: meta.origin, currentTime: state.currentTime });\n  if (type === \'pause\') dispatchBee(el, \'beecast-pause\', { origin: meta.origin, currentTime: state.currentTime });\n  if (type === \'ended\') dispatchBee(el, \'beecast-ended\', { currentTime: state.currentTime, duration: state.duration });\n  if (type === \'seek\') {\n    dispatchBee(el, \'beecast-seek\', {\n      origin: meta.origin,\n      currentTime: state.currentTime,\n      duration: state.duration,\n    });\n  }\n  if (type === \'timeupdate\') {\n    dispatchBee(el, \'beecast-timeupdate\', {\n      currentTime: state.currentTime,\n      duration: state.duration,\n      atLiveEdge: state.atLiveEdge,\n    });\n  }\n  if (type === \'speedchange\') {\n    dispatchBee(el, \'beecast-speedchange\', { speed: state.speed, origin: meta.origin });\n  }\n  if (type === \'durationchange\') {\n    dispatchBee(el, \'beecast-durationchange\', { duration: state.duration });\n  }\n  if (type === \'livechange\') {\n    dispatchBee(el, \'beecast-livechange\', { live: !!state.live, origin: meta.origin });\n  }\n  if (this._lastAtEdge != null && this._lastAtEdge !== state.atLiveEdge) {\n    dispatchBee(el, \'beecast-liveedgechange\', { atLiveEdge: state.atLiveEdge });\n  }\n  if (type === \'durationchange\' || type === \'ready\' || type === \'markerchange\') {\n    dispatchBee(el, \'beecast-markerchange\', { markers: state.markers });\n  }\n  this._lastAtEdge = state.atLiveEdge;\n};\n\nfunction publicState(state) {\n  return {\n    status: state.status,\n    currentTime: state.currentTime,\n    duration: state.duration,\n    speed: state.speed,\n    atLiveEdge: state.atLiveEdge,\n    live: state.live,\n    canAppend: state.canAppend,\n    markers: state.markers,\n    dimensions: state.dimensions,\n  };\n}\n\nfunction terminalPlain(snap) {\n  const lines = [];\n  for (let y = 0; y < snap.rows.length; y++) {\n    let s = \'\';\n    for (const run of snap.rows[y]) s += run.text;\n    lines.push(s.replace(/\\s+$/, \'\'));\n  }\n  return lines.join(\'\\n\');\n}\n\nPlayer.prototype.buildDom = function (mount, cfg) {\n  const self = this;\n  const root = document.createElement(\'div\');\n  root.className = \'beecast-player\';\n  root.setAttribute(\'part\', \'root\');\n  root.tabIndex = 0;\n  root.setAttribute(\'role\', \'application\');\n  root.setAttribute(\'aria-label\', \'Terminal recording player\');\n\n  let bar = \'\';\n  if (cfg.play || cfg.seek || cfg.time || cfg.speed || cfg.chapters || cfg.fullscreen || cfg.live) {\n    bar = \'<div class=\"sp-bar\" part=\"toolbar\">\';\n    if (cfg.play) {\n      bar += \'<button class=\"sp-play\" type=\"button\" part=\"play-button\" \' +\n        \'aria-label=\"Play\" title=\"play/pause (space)\">\' + ICON_PLAY + \'</button>\';\n    }\n    if (cfg.live) {\n      bar += \'<button class=\"sp-live\" type=\"button\" part=\"live-button\" \' +\n        \'aria-label=\"Go live\" aria-pressed=\"false\" title=\"follow the live edge\">\u{25cf} Live</button>\';\n    }\n    if (cfg.time) bar += \'<span class=\"sp-time\" part=\"current-time\" aria-hidden=\"true\">0:00</span>\';\n    if (cfg.seek) {\n      bar += \'<div class=\"sp-seek\" part=\"seek\" role=\"slider\" tabindex=\"0\" \' +\n        \'aria-label=\"Seek\" aria-valuemin=\"0\" aria-valuemax=\"0\" aria-valuenow=\"0\">\' +\n        \'<div class=\"sp-fill\"></div><div class=\"sp-markers\"></div></div>\';\n    }\n    if (cfg.time) bar += \'<span class=\"sp-dur\" part=\"duration\" aria-hidden=\"true\">0:00</span>\';\n    if (cfg.chapters) {\n      bar += \'<button class=\"sp-chapbtn\" type=\"button\" part=\"chapter-button\" \' +\n        \'aria-label=\"Chapters\" aria-expanded=\"false\" title=\"chapters (c)\" hidden>\u{2630}</button>\';\n    }\n    if (cfg.speed) {\n      bar += \'<span class=\"sp-speedwrap\">\' +\n        \'<button class=\"sp-speed\" type=\"button\" part=\"speed-button\" \' +\n        \'aria-label=\"Playback speed\" aria-haspopup=\"menu\" aria-expanded=\"false\" \' +\n        \'title=\"speed (&lt; / &gt;)\">1\u{d7}</button>\' +\n        \'<div class=\"sp-speedmenu\" part=\"speed-menu\" role=\"menu\" hidden></div></span>\';\n    }\n    if (cfg.fullscreen) {\n      bar += \'<button class=\"sp-fs\" type=\"button\" part=\"fullscreen-button\" \' +\n        \'aria-label=\"Fullscreen\" title=\"fullscreen (f)\">\u{26f6}</button>\';\n    }\n    bar += \'</div>\';\n  }\n\n  root.innerHTML =\n    \'<div class=\"sp-screen-box\" part=\"screen-box\">\' +\n    \'<pre class=\"sp-screen\" part=\"screen\" aria-hidden=\"true\"></pre>\' +\n    (this.accessibility === \'snapshot\'\n      ? \'<pre class=\"sp-a11y\" part=\"terminal-text\"></pre>\'\n      : \'\') +\n    \'<div class=\"sp-overlay\" part=\"overlay\" hidden role=\"button\" tabindex=\"0\" \' +\n    \'aria-label=\"Play recording\"><span class=\"sp-bigplay\" aria-hidden=\"true\">\' + BIG_PLAY + \'</span></div>\' +\n    \'<div class=\"sp-chapters\" part=\"chapter-panel\" role=\"menu\" hidden></div>\' +\n    \'</div>\' + bar;\n\n  mount.appendChild(root);\n  this.root = root;\n  this.screenEl = root.querySelector(\'.sp-screen\');\n  this.a11yEl = root.querySelector(\'.sp-a11y\');\n  this.playBtn = root.querySelector(\'.sp-play\');\n  this.liveBtn = root.querySelector(\'.sp-live\');\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  this.speedMenuEl = root.querySelector(\'.sp-speedmenu\');\n  this.chapBtn = root.querySelector(\'.sp-chapbtn\');\n  this.chaptersEl = root.querySelector(\'.sp-chapters\');\n  this.fsBtn = root.querySelector(\'.sp-fs\');\n  this.overlayEl = root.querySelector(\'.sp-overlay\');\n  this.marksEl = root.querySelector(\'.sp-markers\');\n\n  if (this.playBtn) {\n    this.playBtn.addEventListener(\'click\', function () { self.toggle(\'pointer\'); });\n  }\n  if (this.liveBtn) {\n    this.liveBtn.addEventListener(\'click\', function (ev) {\n      ev.stopPropagation();\n      // Go live (or leave live). Going live parks at the edge \u{2014} no play(), no overlay.\n      self.setLive(!self.controller.getState().live, \'pointer\');\n      try { root.focus({ preventScroll: true }); } catch (_) { root.focus(); }\n    });\n  }\n  if (this.overlayEl) {\n    const playFromOverlay = function (ev) {\n      ev.stopPropagation();\n      self.play(\'pointer\');\n      try { root.focus({ preventScroll: true }); } catch (_) { root.focus(); }\n    };\n    this.overlayEl.addEventListener(\'click\', playFromOverlay);\n    this.overlayEl.addEventListener(\'keydown\', function (ev) {\n      if (ev.key === \'Enter\' || ev.key === \' \') { ev.preventDefault(); playFromOverlay(ev); }\n    });\n  }\n  if (this.speedBtn) {\n    this.speedBtn.addEventListener(\'click\', function (ev) {\n      ev.stopPropagation();\n      self.toggleSpeedMenu();\n    });\n  }\n  if (this.chapBtn) {\n    this.chapBtn.addEventListener(\'click\', function () { self.toggleChapters(); });\n  }\n  if (this.fsBtn) {\n    this.fsBtn.addEventListener(\'click\', function () { self.toggleFullscreen(); });\n  }\n  if (this.seekEl) {\n    const seekTo = function (ev, origin) {\n      const r = self.seekEl.getBoundingClientRect();\n      const frac = Math.min(1, Math.max(0, (ev.clientX - r.left) / (r.width || 1)));\n      const dur = self.controller.cast.duration;\n      self.seek(frac * dur, origin || \'pointer\');\n    };\n    this.seekEl.addEventListener(\'mousedown\', function (ev) {\n      seekTo(ev, \'pointer\');\n      const move = function (e) { seekTo(e, \'pointer\'); };\n      const up = function () {\n        document.removeEventListener(\'mousemove\', move);\n        document.removeEventListener(\'mouseup\', up);\n      };\n      document.addEventListener(\'mousemove\', move);\n      document.addEventListener(\'mouseup\', up);\n    });\n    this.seekEl.addEventListener(\'keydown\', function (ev) {\n      const dur = self.controller.cast.duration;\n      const now = self.getCurrentTime();\n      let t = null;\n      if (ev.key === \'ArrowLeft\') t = now - SEEK_STEP_SECS;\n      else if (ev.key === \'ArrowRight\') t = now + SEEK_STEP_SECS;\n      else if (ev.key === \'PageDown\') t = now - 30;\n      else if (ev.key === \'PageUp\') t = now + 30;\n      else if (ev.key === \'Home\') t = 0;\n      else if (ev.key === \'End\') t = dur;\n      else return;\n      ev.preventDefault();\n      self.seek(t, \'keyboard\');\n    });\n  }\n  this.keyHandler = function (ev) { self.onKey(ev); };\n  root.addEventListener(\'keydown\', this.keyHandler);\n  root.addEventListener(\'click\', function () {\n    try { root.focus({ preventScroll: true }); } catch (_) { root.focus(); }\n  });\n};\n\nPlayer.prototype.syncOverlay = function (state) {\n  if (!this.overlayEl) return;\n  // Show whenever playback is not running (start, paused mid-cast, ended) so the\n  // big glyph is the obvious \"press play\" affordance \u{2014} not only at t = 0.\n  // Declared-live is following the growing edge: no play overlay (that would invite\n  // play(), which drops live and replays from the top).\n  const show = !state.live && state.status !== \'playing\' && state.duration > 0;\n  this.overlayEl.hidden = !show;\n};\n\nPlayer.prototype.renderBar = function (state) {\n  if (this.timeEl) this.timeEl.textContent = fmtClock(state.currentTime);\n  if (this.durEl) this.durEl.textContent = fmtClock(state.duration);\n  if (this.fillEl) {\n    this.fillEl.style.width = (state.duration > 0\n      ? Math.min(100, (state.currentTime / state.duration) * 100)\n      : 0) + \'%\';\n  }\n  if (this.seekEl) {\n    this.seekEl.setAttribute(\'aria-valuemin\', \'0\');\n    this.seekEl.setAttribute(\'aria-valuemax\', String(Math.floor(state.duration)));\n    this.seekEl.setAttribute(\'aria-valuenow\', String(Math.floor(state.currentTime)));\n    this.seekEl.setAttribute(\'aria-valuetext\', fmtClock(state.currentTime) + \' of \' + fmtClock(state.duration));\n  }\n  if (this.playBtn) {\n    const playing = state.status === \'playing\';\n    this.playBtn.textContent = playing ? ICON_PAUSE : ICON_PLAY;\n    this.playBtn.setAttribute(\'aria-label\', playing ? \'Pause\' : \'Play\');\n    this.playBtn.setAttribute(\'aria-pressed\', playing ? \'true\' : \'false\');\n  }\n  if (this.liveBtn) {\n    const live = !!state.live;\n    this.liveBtn.classList.toggle(\'on\', live);\n    this.liveBtn.setAttribute(\'aria-pressed\', live ? \'true\' : \'false\');\n    this.liveBtn.setAttribute(\'aria-label\', live ? \'Live (following)\' : \'Go live\');\n    this.liveBtn.title = live ? \'following the live edge (seek back to leave)\' : \'follow the live edge\';\n  }\n  if (this.speedBtn) {\n    this.speedBtn.textContent = String(state.speed).replace(/\\.0$/, \'\') + \'\\u00d7\';\n  }\n};\n\nPlayer.prototype.layoutMarkers = function (state) {\n  if (!this.marksEl) return;\n  this.marksEl.innerHTML = \'\';\n  if (!(state.duration > 0)) return;\n  for (const m of state.markers) {\n    const tick = document.createElement(\'div\');\n    tick.className = \'sp-marker\' + (m.type === \'annotation\' ? \' sp-marker-ann\' : \'\');\n    tick.style.left = Math.min(100, (m.time / state.duration) * 100) + \'%\';\n    if (m.color) tick.style.background = m.color;\n    tick.title = fmtClock(m.time) + (m.label ? \' \' + m.label : \'\');\n    this.marksEl.appendChild(tick);\n  }\n};\n\nPlayer.prototype.toggleChapters = function (force) {\n  if (!this.chaptersEl) return;\n  const show = force != null ? force : this.chaptersEl.hidden;\n  // Focus must be checked BEFORE hiding: hiding the focused row silently moves\n  // focus to <body>, and the \u{2630} button would never get it back.\n  const hadFocus = !show && typeof document !== \'undefined\' &&\n    this.chaptersEl.contains(document.activeElement);\n  this.chaptersEl.hidden = !show;\n  if (this.chapBtn) this.chapBtn.setAttribute(\'aria-expanded\', show ? \'true\' : \'false\');\n  if (show) this.renderChapters(this.controller.getState());\n  else if (hadFocus && this.chapBtn) {\n    try { this.chapBtn.focus({ preventScroll: true }); } catch (_) {}\n  }\n};\n\nPlayer.prototype.renderChapters = function (state) {\n  if (!this.chaptersEl) return;\n  const self = this;\n  this.chaptersEl.innerHTML = \'\';\n  const now = state.currentTime;\n  let currentId = null;\n  for (let i = 0; i < state.markers.length; i++) {\n    if (state.markers[i].time <= now + 1e-9) currentId = state.markers[i].id;\n  }\n  for (const m of state.markers) {\n    const row = document.createElement(\'button\');\n    row.type = \'button\';\n    row.className = \'sp-chap\' + (m.id === currentId ? \' sp-chap-on\' : \'\') +\n      (m.type === \'annotation\' ? \' sp-chap-ann\' : \'\');\n    row.setAttribute(\'role\', \'menuitem\');\n    const t = document.createElement(\'span\');\n    t.className = \'sp-chap-t\';\n    t.textContent = fmtClock(m.time);\n    row.appendChild(t);\n    row.appendChild(document.createTextNode(m.label || \'\'));\n    row.addEventListener(\'click\', (function (marker) {\n      return function (ev) {\n        ev.stopPropagation();\n        const el = self.eventTarget || self.root;\n        // Cancellable marker selection (Phase 5).\n        let cancelled = false;\n        if (el && typeof CustomEvent !== \'undefined\') {\n          try {\n            const ce = new CustomEvent(\'beecast-markerselect\', {\n              detail: { marker: marker },\n              bubbles: true,\n              composed: true,\n              cancelable: true,\n            });\n            cancelled = !el.dispatchEvent(ce);\n          } catch (_) {}\n        }\n        if (cancelled) return;\n        // Seek only \u{2014} chapter picks must not start playback (same as [ ] / \u{2190}\u{2192}).\n        self.seek(marker.time, \'marker\');\n        self.toggleChapters(false);\n      };\n    })(m));\n    this.chaptersEl.appendChild(row);\n  }\n};\n\nPlayer.prototype.syncChaptersUi = function (state) {\n  if (this.chapBtn) this.chapBtn.hidden = !(state.markers && state.markers.length);\n  if (this.chaptersEl && !this.chaptersEl.hidden) this.renderChapters(state);\n};\n\nPlayer.prototype.toggleSpeedMenu = function (force) {\n  if (!this.speedMenuEl) return;\n  const show = force != null ? force : this.speedMenuEl.hidden;\n  if (show === !this.speedMenuEl.hidden) return;\n  const self = this;\n  if (show) {\n    this.renderSpeedMenu();\n    this.speedMenuEl.hidden = false;\n    if (this.speedBtn) this.speedBtn.setAttribute(\'aria-expanded\', \'true\');\n    this.speedAway = function () { self.toggleSpeedMenu(false); };\n    document.addEventListener(\'click\', this.speedAway);\n  } else {\n    this.speedMenuEl.hidden = true;\n    if (this.speedBtn) {\n      this.speedBtn.setAttribute(\'aria-expanded\', \'false\');\n      try { this.speedBtn.focus({ preventScroll: true }); } catch (_) {}\n    }\n    if (this.speedAway) { document.removeEventListener(\'click\', this.speedAway); this.speedAway = null; }\n  }\n};\n\nPlayer.prototype.renderSpeedMenu = function () {\n  if (!this.speedMenuEl) return;\n  const self = this;\n  const speed = this.controller.speed;\n  this.speedMenuEl.innerHTML = \'\';\n  for (let i = SPEEDS.length - 1; i >= 0; i--) {\n    const v = SPEEDS[i];\n    const b = document.createElement(\'button\');\n    b.type = \'button\';\n    b.className = \'sp-speedopt\' + (v === speed ? \' sp-on\' : \'\');\n    b.setAttribute(\'role\', \'menuitemradio\');\n    b.setAttribute(\'aria-checked\', v === speed ? \'true\' : \'false\');\n    b.textContent = String(v).replace(/\\.0$/, \'\') + \'\u{d7}\';\n    b.addEventListener(\'click\', (function (s) {\n      return function (ev) {\n        ev.stopPropagation();\n        self.setSpeed(s, \'pointer\');\n        self.toggleSpeedMenu(false);\n      };\n    })(v));\n    this.speedMenuEl.appendChild(b);\n  }\n};\n\nPlayer.prototype.onKey = function (ev) {\n  if (ev.metaKey || ev.ctrlKey || ev.altKey) return;\n  // Escape closes menus first.\n  if (ev.key === \'Escape\') {\n    if (this.speedMenuEl && !this.speedMenuEl.hidden) {\n      this.toggleSpeedMenu(false);\n      ev.preventDefault();\n      return;\n    }\n    if (this.chaptersEl && !this.chaptersEl.hidden) {\n      this.toggleChapters(false);\n      ev.preventDefault();\n      return;\n    }\n  }\n  const k = ev.key;\n  if (k === \' \') this.toggle(\'keyboard\');\n  else if (k === \'ArrowLeft\') this.seek(this.getCurrentTime() - SEEK_STEP_SECS, \'keyboard\');\n  else if (k === \'ArrowRight\') this.seek(this.getCurrentTime() + SEEK_STEP_SECS, \'keyboard\');\n  else if (k === \'<\' || k === \',\') this.controller.cycleSpeed(-1, \'keyboard\');\n  else if (k === \'>\' || k === \'.\') this.controller.cycleSpeed(1, \'keyboard\');\n  else if (k === \'[\') this.controller.jumpMarker(-1, \'keyboard\');\n  else if (k === \']\') this.controller.jumpMarker(1, \'keyboard\');\n  else if (k === \'c\' || k === \'C\') this.toggleChapters();\n  else if (k === \'f\' || k === \'F\') this.toggleFullscreen();\n  else return;\n  ev.preventDefault();\n  ev.stopPropagation();\n};\n\n// True when `mount`\'s height does not depend on `box` \u{2014} i.e. the embedding page gave the\n// mount a definite height (%, vh, flex/grid stretch, \u{2026}). A content-sized mount shrinks when\n// the box does; vertical fit against that height is a ResizeObserver shrink ratchet.\nPlayer.prototype.mountHeightIsDefinite = function (mount, box) {\n  if (!mount || !box) return false;\n  const prev = box.style.height;\n  const before = mount.clientHeight;\n  box.style.height = \'0px\';\n  const after = mount.clientHeight;\n  box.style.height = prev;\n  return before > 0 && before === after;\n};\n\nPlayer.prototype.layout = function () {\n  if (!this.fit || !this.root || this._layouting) return;\n  this._layouting = true;\n  try {\n    const box = this.root.querySelector(\'.sp-screen-box\');\n    if (!box || !this.screenEl) return;\n    this.screenEl.style.transform = \'\';\n    this.screenEl.style.marginLeft = \'\';\n    const rect = this.screenEl.getBoundingClientRect();\n    const naturalW = rect.width, naturalH = rect.height;\n    if (!(naturalW > 0 && naturalH > 0)) return;\n\n    const fs = typeof document !== \'undefined\' ? document.fullscreenElement : null;\n    const rootFs = fs === this.root;\n    const wrapFs = !!(this.fsEl && fs === this.fsEl);\n    const bar = this.root.querySelector(\'.sp-bar\');\n    const barH = bar ? Math.max(bar.offsetHeight, Math.ceil(bar.getBoundingClientRect().height)) : 0;\n\n    // Width budget: the screen pane, falling back to the mount/fullscreen host.\n    let availW = box.clientWidth;\n    if (!(availW > 0)) {\n      const host = rootFs ? this.root : (wrapFs ? this.fsEl : this.root.parentNode);\n      availW = host ? host.clientWidth : 0;\n    }\n    let scale = availW > 0 && naturalW > availW ? availW / naturalW : 1;\n\n    // fit:\'both\' honors a definite mount height only. Fullscreen hosts are definite by\n    // construction; a content-sized parent (scsh\'s live dashboard pane, etc.) must not\n    // drive vertical scale \u{2014} measure\u{2192}scale\u{2192}write\u{2192}ResizeObserver would ratchet forever.\n    if (this.fit === \'both\') {\n      let availH = 0;\n      let definite = false;\n      if (rootFs) {\n        availH = this.root.clientHeight - barH;\n        definite = true;\n      } else if (wrapFs && this.fsEl) {\n        // Prefer the mount (.cast-player) height: the fullscreen host may also include a\n        // page toolbar above the player (scsh). Measuring fsEl and only subtracting our\n        // own bar over-stated availH, then a later ResizeObserver tick corrected it \u{2014}\n        // the visible \"grows after a second and clips 1\u{d7}\" jump.\n        const mount = this.root.parentNode;\n        const hostH = (mount && mount.clientHeight > 0) ? mount.clientHeight : this.fsEl.clientHeight;\n        availH = hostH - barH;\n        definite = true;\n      } else if (this.root.parentNode) {\n        const mount = this.root.parentNode;\n        definite = this.mountHeightIsDefinite(mount, box);\n        if (definite) availH = mount.clientHeight - barH;\n      }\n      if (definite && availH > 40 && naturalH * scale > availH) {\n        scale = Math.min(scale, availH / naturalH);\n      }\n    }\n\n    const displayH = naturalH * scale;\n    const displayW = naturalW * scale;\n    const transform = scale < 1 ? \'scale(\' + scale + \')\' : \'\';\n    const height = displayH + \'px\';\n    const paneW = box.clientWidth || availW;\n    const margin = paneW > displayW ? (paneW - displayW) / 2 + \'px\' : \'\';\n    // Idempotent: skip style writes that would only re-arm ResizeObserver.\n    if (this._layoutScale === scale && this.screenEl.style.transform === transform &&\n        box.style.height === height && this.screenEl.style.marginLeft === margin) {\n      return;\n    }\n    this._layoutScale = scale;\n    this.screenEl.style.transform = transform;\n    // The layout box must match the DISPLAY size: scale() does not shrink layout, and a\n    // taller box was what pushed the control bar off-screen in fullscreen.\n    box.style.height = height;\n    this.screenEl.style.marginLeft = margin;\n  } finally {\n    this._layouting = false;\n  }\n};\n\nPlayer.prototype.toggleFullscreen = function () {\n  const el = this.fsEl || this.root;\n  if (!el) return;\n  if (document.fullscreenElement === el) {\n    if (document.exitFullscreen) document.exitFullscreen();\n  } else if (el.requestFullscreen) {\n    el.requestFullscreen();\n  }\n};\n\nPlayer.prototype.play = function (origin) { this.controller.play(origin); };\nPlayer.prototype.pause = function (origin) { this.controller.pause(origin); };\nPlayer.prototype.toggle = function (origin) { this.controller.toggle(origin); };\nPlayer.prototype.seek = function (t, origin) {\n  this.controller.seek(t, { origin: origin || \'api\' });\n};\nPlayer.prototype.setSpeed = function (v, origin) { this.controller.setSpeed(v, origin); };\nPlayer.prototype.getCurrentTime = function () { return this.controller.getCurrentTime(); };\nPlayer.prototype.getState = function () { return this.controller.getState(); };\nPlayer.prototype.append = function (text) { this.controller.append(text); };\nPlayer.prototype.setLive = function (on, origin) { this.controller.setLive(on, origin || \'api\'); };\nPlayer.prototype.subscribe = function (fn) { return this.controller.subscribe(fn); };\n\nPlayer.prototype.dispose = function () {\n  if (this.disposed) return;\n  this.disposed = true;\n  if (this.unsubscribe) { this.unsubscribe(); this.unsubscribe = null; }\n  this.controller.dispose();\n  if (this.speedAway) { document.removeEventListener(\'click\', this.speedAway); this.speedAway = null; }\n  if (this.resizeObs) { try { this.resizeObs.disconnect(); } catch (_) {} this.resizeObs = null; }\n  if (this.fsHandler) {\n    try { document.removeEventListener(\'fullscreenchange\', this.fsHandler); } catch (_) {}\n    this.fsHandler = null;\n  }\n  if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root);\n  this.root = null;\n};\n\n// ---- Web Component ---------------------------------------------------------------------\n// Preferred browser integration. Light DOM so the embedding page\'s inlined PLAYER_CSS\n// styles the controls (Shadow DOM would require shipping CSS inside the JS bundle).\n// part attributes mark stable styling hooks for when an open shadow root is added later.\nfunction registerComponent() {\n  if (typeof customElements === \'undefined\' || typeof HTMLElement === \'undefined\') return;\n  if (customElements.get(\'beecast-player\')) return;\n\n  class BeeCastPlayerElement extends HTMLElement {\n    static get observedAttributes() {\n      // Only the attributes that are live after mount; the rest are read at mount time.\n      return [\'fit\', \'speed\', \'theme\'];\n    }\n\n    constructor() {\n      super();\n      this._player = null;\n      this._pending = {};\n      this._connected = false;\n    }\n\n    connectedCallback() {\n      this._connected = true;\n      this.style.display = this.style.display || \'block\';\n      if (!this._player) this._mountPlayer();\n    }\n\n    disconnectedCallback() {\n      this._connected = false;\n      if (this._player) {\n        this._player.dispose();\n        this._player = null;\n      }\n    }\n\n    attributeChangedCallback(name, oldV, newV) {\n      if (oldV === newV || !this._player) return;\n      if (name === \'speed\') this._player.setSpeed(Number(newV) || 1);\n      if (name === \'fit\') { this._player.fit = newV || null; this._player.layout(); }\n      if (name === \'theme\' && this._player.root) {\n        if (newV) this._player.root.setAttribute(\'data-theme\', newV);\n        else this._player.root.removeAttribute(\'data-theme\');\n      }\n    }\n\n    _optsFromAttributes() {\n      const opts = {};\n      if (this.hasAttribute(\'fit\')) opts.fit = this.getAttribute(\'fit\');\n      if (this.hasAttribute(\'autoplay\')) opts.autoPlay = this.getAttribute(\'autoplay\') !== \'false\';\n      if (this.hasAttribute(\'idle-time-limit\')) {\n        opts.idleTimeLimit = Number(this.getAttribute(\'idle-time-limit\'));\n      }\n      if (this.hasAttribute(\'speed\')) opts.speed = Number(this.getAttribute(\'speed\'));\n      if (this.hasAttribute(\'start-at\')) opts.startAt = this.getAttribute(\'start-at\');\n      if (this.hasAttribute(\'controls\')) {\n        opts.controls = this.getAttribute(\'controls\') === \'false\' ? false : true;\n      }\n      if (this.hasAttribute(\'accessibility\')) {\n        opts.accessibility = this.getAttribute(\'accessibility\');\n      }\n      if (this._pending.markers) opts.markers = this._pending.markers;\n      if (this._pending.speed != null) opts.speed = this._pending.speed;\n      if (this._pending.startAt != null) opts.startAt = this._pending.startAt;\n      if (this._pending.fit != null) opts.fit = this._pending.fit;\n      if (this._pending.controls != null) opts.controls = this._pending.controls;\n      if (this._pending.autoPlay != null) opts.autoPlay = this._pending.autoPlay;\n      if (this._pending.idleTimeLimit != null) opts.idleTimeLimit = this._pending.idleTimeLimit;\n      if (this._pending.fullscreenEl) opts.fullscreenEl = this._pending.fullscreenEl;\n      if (this._pending.clock) opts.clock = this._pending.clock;\n      if (this._pending.accessibility) opts.accessibility = this._pending.accessibility;\n      return opts;\n    }\n\n    _mountPlayer() {\n      while (this.firstChild) this.removeChild(this.firstChild);\n      const data = this._pending.cast != null ? this._pending.cast\n        : (this._pending.data != null ? this._pending.data : \'\');\n      const opts = this._optsFromAttributes();\n      const player = new Player({ data: data, source: this._pending.source }, this, opts);\n      player.eventTarget = this;\n      this._player = player;\n      const theme = this.getAttribute(\'theme\') || this._pending.theme;\n      if (theme && player.root) player.root.setAttribute(\'data-theme\', theme);\n    }\n\n    load(opts) {\n      opts = opts || {};\n      if (opts.cast != null) this._pending.cast = opts.cast;\n      if (opts.data != null) this._pending.cast = opts.data;\n      if (opts.metadata) this._pending.metadata = opts.metadata;\n      if (opts.markers) this._pending.markers = opts.markers;\n      if (opts.source) this._pending.source = opts.source;\n      if (opts.speed != null) this._pending.speed = opts.speed;\n      if (opts.startAt != null) this._pending.startAt = opts.startAt;\n      if (opts.autoPlay != null) this._pending.autoPlay = opts.autoPlay;\n      if (!this._connected) return;\n      if (this._player) {\n        this._player.controller.load({\n          data: this._pending.cast,\n          markers: this._pending.markers,\n          startAt: this._pending.startAt,\n          autoPlay: this._pending.autoPlay,\n        });\n        if (opts.speed != null) this._player.setSpeed(opts.speed);\n      } else {\n        this._mountPlayer();\n      }\n    }\n\n    play() { if (this._player) this._player.play(\'api\'); }\n    pause() { if (this._player) this._player.pause(\'api\'); }\n    toggle() { if (this._player) this._player.toggle(\'api\'); }\n    seek(t) { if (this._player) this._player.seek(t, \'api\'); }\n    setSpeed(v) { if (this._player) this._player.setSpeed(v, \'api\'); }\n    append(t) { if (this._player) this._player.append(t); }\n    getCurrentTime() { return this._player ? this._player.getCurrentTime() : 0; }\n    dispose() {\n      if (this._player) { this._player.dispose(); this._player = null; }\n    }\n\n    get state() { return this._player ? publicState(this._player.getState()) : null; }\n    get cast() { return this._pending.cast; }\n    set cast(v) { this._pending.cast = v; if (this._player) this.load({ cast: v }); }\n    get markers() { return this._pending.markers; }\n    set markers(v) {\n      this._pending.markers = v;\n      if (this._player) this._player.controller.setMarkers(v);\n    }\n    get speed() {\n      return this._player ? this._player.controller.speed : Number(this.getAttribute(\'speed\')) || 1;\n    }\n    set speed(v) { this._pending.speed = v; if (this._player) this._player.setSpeed(v); }\n  }\n\n  try {\n    customElements.define(\'beecast-player\', BeeCastPlayerElement);\n    root.BeeCastPlayerElement = BeeCastPlayerElement;\n  } catch (_) {\n    // Legacy create() still works without custom elements.\n  }\n}\n\nregisterComponent();\n\n// ---- public API ------------------------------------------------------------------------\n// BeeCastPlayer.create remains the compatibility factory: a thin Player over the controller.\n// New integrations should prefer <beecast-player> or BeeCastController directly.\nroot.BeeCastPlayer = {\n  create: function (src, mount, opts) { return new Player(src, mount, opts); },\n  Player: Player,\n  elementName: \'beecast-player\',\n  SPEEDS: SPEEDS,\n  // Documented public surface snapshot for compatibility fixtures (Phase 0).\n  publicMethods: [\n    \'create\', \'play\', \'pause\', \'toggle\', \'seek\', \'getCurrentTime\', \'append\', \'dispose\',\n    \'setSpeed\', \'getState\', \'subscribe\',\n  ],\n  supportedCssVariables: [\n    \'--beecast-color-surface\',\n    \'--beecast-color-surface-raised\',\n    \'--beecast-color-text\',\n    \'--beecast-color-text-muted\',\n    \'--beecast-color-accent\',\n    \'--beecast-color-focus\',\n    \'--beecast-color-marker\',\n    \'--beecast-color-error\',\n    \'--beecast-control-height\',\n    \'--beecast-radius\',\n    \'--beecast-font-ui\',\n    \'--beecast-font-terminal\',\n    \'--sp-bg\', \'--sp-fg\',\n    \'--sp-c0\', \'--sp-c1\', \'--sp-c2\', \'--sp-c3\', \'--sp-c4\', \'--sp-c5\', \'--sp-c6\', \'--sp-c7\',\n    \'--sp-c8\', \'--sp-c9\', \'--sp-c10\', \'--sp-c11\', \'--sp-c12\', \'--sp-c13\', \'--sp-c14\', \'--sp-c15\',\n  ],\n  // Readable during migration but not public API \u{2014} do not depend on these.\n  nonPublicFields: [\'playing\', \'pacedPos\', \'eventIdx\', \'cast\', \'term\', \'pacing\', \'raf\'],\n};\n\n})(typeof window !== \'undefined\' ? window : globalThis);\n";
Expand description

The player bundle: vt.js (core) + controller.js (headless playback) + player.js (DOM view, Web Component, legacy factory). Inline it in one <script> element; it performs no network requests and loads no workers.