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, \'&\').replace(/</g, \'<\').replace(/>/g, \'>\');\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 (< / >)\">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.