Skip to main content

HTML_TEMPLATE

Constant HTML_TEMPLATE 

Source
const HTML_TEMPLATE: &str = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>Timeline: @@XIL_TAG_ESCAPED@@</title>\n<style>\n  * { margin: 0; padding: 0; box-sizing: border-box; }\n  body { font-family: \'Segoe UI\', system-ui, sans-serif; background: #1a1a2e; color: #eee; padding: 20px; }\n  h1 { font-size: 1.3em; margin-bottom: 4px; color: #e0e0ff; }\n  .subtitle { color: #888; font-size: 0.9em; margin-bottom: 16px; }\n  .timeline-container { position: relative; overflow-x: auto; overflow-y: visible; padding-bottom: 20px; }\n  .timeline-inner { position: relative; min-width: 100%; }\n  .structure-row { display: flex; align-items: center; height: 20px; margin-bottom: 2px; }\n  .structure-track { position: relative; height: 18px; }\n  .structure-block { position: absolute; top: 0; height: 100%; box-sizing: border-box;\n    border-right: 1px solid #1a1a2e; border-radius: 2px; font-size: 10px; line-height: 18px;\n    padding: 0 5px; color: #d8d8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n  #sections-track .structure-block { background: #3a3a5c; }\n  #scenes-track .structure-block { background: #2e2e46; color: #a8a8c8; }\n  .ruler { height: 30px; position: relative; border-bottom: 1px solid #444; margin-bottom: 4px; }\n  .ruler-tick { position: absolute; top: 0; height: 100%; border-left: 1px solid #555; }\n  .ruler-tick span { position: absolute; top: 2px; left: 4px; font-size: 11px; color: #999; white-space: nowrap; }\n  .layer { display: flex; align-items: center; height: 38px; margin-bottom: 2px; }\n  .layer-label { width: 90px; flex-shrink: 0; font-size: 12px; font-weight: 600; text-transform: uppercase; padding-right: 8px; text-align: right; }\n  .layer-track { position: relative; flex: 1; height: 28px; background: #222238; border-radius: 3px; overflow: visible; }\n  .span { position: absolute; height: 100%; border-radius: 2px; cursor: pointer; min-width: 2px; opacity: 0.85; transition: opacity 0.15s; }\n  .span:hover { opacity: 1; z-index: 10; }\n  .span.playing { outline: 2px solid rgba(255,255,255,0.9); opacity: 1; z-index: 11; }\n  .span.loading { outline: 2px dashed rgba(255,255,255,0.7); opacity: 1; z-index: 11; }\n  #floattip { display: none; position: fixed; background: #333; color: #fff; padding: 6px 10px; border-radius: 4px;\n    font-size: 12px; white-space: nowrap; z-index: 1000; pointer-events: none;\n    box-shadow: 0 2px 8px rgba(0,0,0,0.5); line-height: 1.5; }\n  .c-dialogue { background: #4a9eff; }\n  .c-ambience { background: #4caf50; }\n  .c-music { background: #ffc107; }\n  .c-sfx { background: #ef5350; }\n  .c-vintage-filter { background: #ab7edb; }\n  .ramp-badge { position: absolute; top: 1px; font-size: 9px; font-weight: bold; color: rgba(0,0,0,0.65);\n    line-height: 1; pointer-events: none; z-index: 5; }\n  .ramp-badge.ri { left: 2px; }\n  .ramp-badge.ro { right: 2px; }\n  .ramp-badge.pd { left: 50%; transform: translateX(-50%); }\n  .ramp-badge.vb { right: 2px; bottom: 1px; }\n  .controls { margin-bottom: 12px; display: flex; gap: 8px; align-items: center; }\n  .controls button { background: #333; color: #ccc; border: 1px solid #555; padding: 4px 12px; border-radius: 3px; cursor: pointer; font-size: 12px; }\n  .controls button:hover { background: #444; }\n  .zoom-info { font-size: 12px; color: #888; }\n  #xil-player { position: sticky; top: 0; z-index: 200; background: #111;\n    padding: 6px 12px; border-bottom: 1px solid #444; margin-bottom: 8px; display: none; }\n  #xil-player.active { display: block; }\n  #player-label { font-size: 11px; color: #aaa; margin-bottom: 3px; white-space: nowrap;\n    overflow: hidden; text-overflow: ellipsis; }\n  #sfx-modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.65); z-index:2000; }\n  #sfx-modal { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);\n    background:#2a2a3e; border:1px solid #555; border-radius:8px; padding:20px;\n    min-width:300px; max-width:460px; color:#eee; font-size:13px; }\n  #sfx-modal h3 { margin-bottom:12px; font-size:0.9em; color:#b0b0ff; word-break:break-all; font-weight:600; }\n  .sfx-field-wrap { margin-bottom:10px; }\n  .sfx-field-wrap label { display:block; font-size:11px; color:#aaa; margin-bottom:3px; text-transform:uppercase; letter-spacing:0.04em; }\n  .sfx-field-wrap input[type=number] { width:100%; background:#111; color:#eee; border:1px solid #444; border-radius:4px; padding:4px 8px; font-size:13px; }\n  .sfx-field-wrap input[type=number]:focus { outline:none; border-color:#6688cc; }\n  .sfx-field-wrap input[disabled] { opacity:0.3; cursor:not-allowed; }\n  .sfx-help { font-size:10px; color:#666; margin-top:3px; line-height:1.3; }\n  #sfx-modal-status { min-height:1.2em; font-size:12px; margin-top:8px; color:#aaa; }\n  .sfx-modal-btns { margin-top:14px; display:flex; gap:8px; }\n  .sfx-modal-btns button { background:#333; color:#ccc; border:1px solid #555; padding:5px 14px;\n    border-radius:4px; cursor:pointer; font-size:12px; }\n  .sfx-modal-btns button:hover:not([disabled]) { background:#444; }\n  .sfx-modal-btns button[disabled] { opacity:0.45; cursor:not-allowed; }\n  #sfx-modal-save { background:#1a3a1a; border-color:#4caf50; color:#8fca8f; }\n  #sfx-modal-save:hover:not([disabled]) { background:#1e481e; }\n  #sfx-defaults-section { margin-top:10px; border-top:1px solid #444; padding-top:8px; }\n  #sfx-defaults-section summary { cursor:pointer; font-size:11px; color:#b0b0ff;\n    text-transform:uppercase; letter-spacing:0.04em; }\n  #sfx-defaults-warning { margin:6px 0 8px; }\n  #dlg-modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.65); z-index:2000; }\n  #dlg-modal { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);\n    background:#2a2a3e; border:1px solid #555; border-radius:8px; padding:20px;\n    min-width:320px; max-width:480px; color:#eee; font-size:13px; }\n  #dlg-modal h3 { margin-bottom:12px; font-size:0.9em; color:#b0b0ff; font-weight:600; }\n  .dlg-field-wrap { margin-bottom:10px; }\n  .dlg-field-wrap label { display:block; font-size:11px; color:#aaa; margin-bottom:3px; text-transform:uppercase; letter-spacing:0.04em; }\n  .dlg-field-wrap select, .dlg-field-wrap input[type=text], .dlg-field-wrap textarea {\n    width:100%; background:#111; color:#eee; border:1px solid #444; border-radius:4px; padding:4px 8px; font-size:13px; font-family:inherit; }\n  .dlg-field-wrap textarea { resize:vertical; min-height:70px; }\n  .dlg-field-wrap select:focus, .dlg-field-wrap input:focus, .dlg-field-wrap textarea:focus { outline:none; border-color:#6688cc; }\n  #dlg-modal-status { min-height:1.2em; font-size:12px; margin-top:8px; color:#aaa; }\n  #dlg-modal-save { background:#1a3a1a; border-color:#4caf50; color:#8fca8f; }\n  #dlg-modal-save:hover:not([disabled]) { background:#1e481e; }\n  #transport { display:flex; gap:14px; align-items:center; margin-bottom:10px;\n    background:#222238; padding:6px 12px; border-radius:4px; }\n  #transport-play, #transport-restart { background:#333; color:#eee; border:1px solid #666; width:34px; height:26px;\n    border-radius:4px; cursor:pointer; font-size:13px; line-height:1; }\n  #transport-play:hover, #transport-restart:hover { background:#444; }\n  #transport-time { font-size:12px; color:#b0b0ff; font-variant-numeric:tabular-nums; min-width:110px; }\n  #transport-mutes { display:flex; gap:10px; }\n  .mute-caption { font-size:11px; color:#777; text-transform:uppercase; letter-spacing:0.05em; align-self:center; }\n  .mute-toggle { font-size:11px; color:#aaa; cursor:pointer; user-select:none; display:flex; align-items:center; gap:3px; }\n  .mute-toggle input { cursor:pointer; margin:0; }\n  .mute-toggle input:disabled { opacity:0.3; cursor:not-allowed; }\n  .mute-toggle:has(input:checked) { color:#ef5350; text-decoration:line-through; }\n  #transport-hint { display:none; font-size:12px; color:#777; font-style:italic; }\n  #playhead { display:none; position:absolute; top:0; bottom:0; width:2px;\n    background:#ff5252; z-index:50; pointer-events:none;\n    box-shadow:0 0 4px rgba(255,82,82,0.7); }\n  #ruler { cursor: pointer; }\n  #mix-loading { display:none; align-items:center; gap:12px; margin-bottom:10px;\n    background:#1d2b3a; border:1px solid #345; padding:6px 12px; border-radius:4px; }\n  #mix-loading.visible { display:flex; }\n  #mix-loading.error { background:#3a1d1d; border-color:#844; }\n  #mix-loading.error #mix-loading-text { color:#ef9a9a; }\n  #mix-loading-cancel { background:#333; color:#ccc; border:1px solid #666; padding:2px 10px;\n    border-radius:3px; cursor:pointer; font-size:11px; }\n  #mix-loading-cancel:hover { background:#444; }\n  #mix-loading-text { font-size:12px; color:#9fd0ff; font-variant-numeric:tabular-nums; }\n  #mix-loading-layers { display:flex; gap:10px; font-size:11px; color:#7a8aa0;\n    font-variant-numeric:tabular-nums; }\n</style>\n</head>\n<body>\n<div id=\"xil-player\">\n  <div id=\"player-label\"></div>\n  <audio id=\"audio-el\" controls style=\"width:100%;height:36px;\"></audio>\n</div>\n<h1>Timeline: @@XIL_TAG_ESCAPED@@</h1>\n<p class=\"subtitle\">Duration: @@XIL_DURATION@@ &middot; @@XIL_SPAN_COUNT@@ assets across 5 layers &middot; Generated @@XIL_GENERATED_AT@@</p>\n<div class=\"controls\">\n  <button onclick=\"zoomIn()\">Zoom +</button>\n  <button onclick=\"zoomOut()\">Zoom &minus;</button>\n  <button onclick=\"zoomReset()\">Reset</button>\n  <span class=\"zoom-info\" id=\"zoom-info\">100%</span>\n</div>\n<div id=\"transport\">\n  <button id=\"transport-restart\" title=\"Return to start\" aria-label=\"Return to start\">&#9198;</button>\n  <button id=\"transport-play\" title=\"Play/pause full mix\" aria-label=\"Play/pause full mix\">&#9205;</button>\n  <span id=\"transport-time\">0:00 / 0:00</span>\n  <span id=\"transport-mutes\">\n    <span class=\"mute-caption\">Mute:</span>\n    <label class=\"mute-toggle\" title=\"Mute the Dialogue layer\"><input type=\"checkbox\" data-layer=\"dialogue\">Dlg</label>\n    <label class=\"mute-toggle\" title=\"Mute the SFX layer\"><input type=\"checkbox\" data-layer=\"sfx\">SFX</label>\n    <label class=\"mute-toggle\" title=\"Mute the Music layer\"><input type=\"checkbox\" data-layer=\"music\">Mus</label>\n    <label class=\"mute-toggle\" title=\"Mute the Ambience layer\"><input type=\"checkbox\" data-layer=\"ambience\">Amb</label>\n    <label class=\"mute-toggle\" title=\"Mute the Vintage Filter layer\"><input type=\"checkbox\" data-layer=\"vintage_filter\">VF</label>\n  </span>\n  <span id=\"transport-hint\">run xil daw to enable full-mix playback</span>\n</div>\n\n<div id=\"mix-loading\">\n  <button id=\"mix-loading-cancel\" title=\"Cancel loading\">Cancel</button>\n  <span id=\"mix-loading-text\">Loading audio&hellip;</span>\n  <div id=\"mix-loading-layers\"></div>\n</div>\n\n<div id=\"floattip\"></div>\n<div class=\"timeline-container\" id=\"tc\">\n  <div class=\"timeline-inner\" id=\"ti\">\n    <div class=\"structure-row\"><div class=\"layer-label\">Sections</div><div class=\"structure-track\" id=\"sections-track\"></div></div>\n    <div class=\"structure-row\"><div class=\"layer-label\">Scenes</div><div class=\"structure-track\" id=\"scenes-track\"></div></div>\n    <div class=\"ruler\" id=\"ruler\"></div>\n    <div id=\"layers\"></div>\n    <div id=\"playhead\"></div>\n  </div>\n</div>\n<div id=\"sfx-modal-overlay\">\n  <div id=\"sfx-modal\">\n    <h3 id=\"sfx-modal-title\"></h3>\n    <div id=\"sfx-modal-fields\"></div>\n    <details id=\"sfx-defaults-section\">\n      <summary id=\"sfx-defaults-summary\">Category defaults</summary>\n      <div class=\"sfx-help\" id=\"sfx-defaults-warning\"></div>\n      <div id=\"sfx-defaults-fields\"></div>\n    </details>\n    <div id=\"sfx-modal-status\"></div>\n    <div class=\"sfx-modal-btns\">\n      <button id=\"sfx-modal-save\">Save</button>\n      <button id=\"sfx-modal-cancel\">Cancel</button>\n    </div>\n  </div>\n</div>\n<div id=\"dlg-modal-overlay\">\n  <div id=\"dlg-modal\">\n    <h3 id=\"dlg-modal-title\"></h3>\n    <div id=\"dlg-modal-fields\"></div>\n    <div id=\"dlg-modal-status\"></div>\n    <div class=\"sfx-modal-btns\">\n      <button id=\"dlg-modal-save\">Save</button>\n      <button id=\"dlg-modal-cancel\">Cancel</button>\n    </div>\n  </div>\n</div>\n\n<script>\nconst DATA = @@XIL_DATA_JSON@@;\nconst CLIPS = @@XIL_CLIPS_JSON@@;\nconst LAYER_AUDIO = @@XIL_LAYER_AUDIO_JSON@@;\n@@XIL_SLUG_JS@@\nconst TOTAL = DATA.total_duration_s;\nconst COLORS = {dialogue:\'c-dialogue\', sfx:\'c-sfx\', music:\'c-music\', ambience:\'c-ambience\', vintage_filter:\'c-vintage-filter\'};\nconst LABELS = {dialogue:\'Dialogue\', sfx:\'SFX\', music:\'Music\', ambience:\'Ambience\', vintage_filter:\'Vtg Filter\'};\nfunction escAttr(s) { return String(s).replace(/&/g,\'&amp;\').replace(/\"/g,\'&quot;\').replace(/</g,\'&lt;\'); }\n// vintage_filter has its own category defaults too (mix_common\'s prefix_map),\n// but its spans carry no data-effect-key \u{2014} deliberately excluded, not an oversight.\nconst EDITABLE_LAYERS = {\'sfx\':true,\'music\':true,\'ambience\':true};\nlet zoom = 1;\nconst BASE_WIDTH = Math.max(document.getElementById(\'tc\').clientWidth - 100, 400);\nconst tips = {};  // span index \u{2192} tooltip HTML\nconst tiToSeq = {};  // span index \u{2192} seq number\nconst tiToSpan = {};  // span index \u{2192} span object (resolved audio params)\n\nfunction fmtTime(s) {\n  const m = Math.floor(s/60), sec = Math.floor(s%60);\n  return m + \':\' + String(sec).padStart(2,\'0\');\n}\n\nfunction render() {\n  const W = BASE_WIDTH * zoom;\n  document.getElementById(\'ti\').style.width = W + 100 + \'px\';\n  // Ruler\n  let interval = 30;\n  if (TOTAL > 180) interval = 60;\n  if (TOTAL > 600) interval = 120;\n  let rhtml = \'\';\n  for (let t = 0; t <= TOTAL; t += interval) {\n    rhtml += \'<div class=\"ruler-tick\" style=\"left:calc(90px + \' + (t/TOTAL*W) + \'px)\"><span>\' + fmtTime(t) + \'</span></div>\';\n  }\n  document.getElementById(\'ruler\').innerHTML = rhtml;\n  // Structure bands (sections / scenes) \u{2014} same percent geometry as layer spans,\n  // so they stay aligned with the tracks below at every zoom level.\n  for (const band of [[\'sections-track\', DATA.sections], [\'scenes-track\', DATA.scenes]]) {\n    const track = document.getElementById(band[0]);\n    track.style.width = W + \'px\';\n    let bhtml = \'\';\n    for (const b of (band[1] || [])) {\n      const bleft = b.start_s / TOTAL * 100;\n      const bw = Math.max((b.end_s - b.start_s) / TOTAL * 100, 0.1);\n      const blabel = escAttr(b.label);\n      bhtml += \'<div class=\"structure-block\" style=\"left:\' + bleft + \'%;width:\' + bw + \'%\" title=\"\' + blabel + \'\">\' + blabel + \'</div>\';\n    }\n    track.innerHTML = bhtml;\n  }\n  // Layers\n  let lhtml = \'\';\n  let ti = 0;\n  for (const key of [\'dialogue\',\'sfx\',\'music\',\'ambience\',\'vintage_filter\']) {\n    const spans = DATA.layers[key] || [];\n    lhtml += \'<div class=\"layer\"><div class=\"layer-label\">\' + LABELS[key] + \'</div><div class=\"layer-track\" style=\"width:\'+W+\'px\">\';\n    for (const sp of spans) {\n      const left = sp.start_s / TOTAL * 100;\n      const w = Math.max((sp.end_s - sp.start_s) / TOTAL * 100, 0.15);\n      const dur = (sp.end_s - sp.start_s).toFixed(1);\n      let rampBadges = \'\';\n      let rampTip = \'\';\n      if (sp.ramp_in_s) { rampBadges += \'<span class=\"ramp-badge ri\">\u{2191}</span>\'; rampTip += \'\u{2191} ramp in: \'+sp.ramp_in_s+\'s  \'; }\n      if (sp.ramp_out_s) { rampBadges += \'<span class=\"ramp-badge ro\">\u{2193}</span>\'; rampTip += \'\u{2193} ramp out: \'+sp.ramp_out_s+\'s  \'; }\n      if (sp.play_duration != null) { rampBadges += \'<span class=\"ramp-badge pd\">%</span>\'; rampTip += \'% play: \'+sp.play_duration.toFixed(2)+\'  \'; }\n      if (sp.volume_pct != null && sp.volume_pct !== 100) { rampBadges += \'<span class=\"ramp-badge vb\">\u{1f50a}\'+sp.volume_pct+\'%</span>\'; rampTip += \'\u{1f50a} vol: \'+sp.volume_pct+\'%  \'; }\n      else if (sp.volume_pct != null) { rampTip += \'\u{1f50a} vol: \'+sp.volume_pct+\'%  \'; }\n      const tipExtra = rampTip ? \'<br><span style=\"opacity:0.8\">\'+rampTip.trim()+\'</span>\' : \'\';\n      const snippetLine = sp.snippet ? \'<br><em style=\"opacity:0.75\">\'+sp.snippet.replace(/</g,\'&lt;\')+\'\u{2026}</em>\' : \'\';\n      const modelLine = sp.tts_model ? \'<br><span style=\"opacity:0.55;font-size:0.85em\">\u{26a1}\u{fe0f} \'+sp.tts_model+\'</span>\' : \'\';\n      const seqPrefix = sp.seq != null ? \'<span style=\"opacity:0.6\">#\'+String(sp.seq).padStart(3,\'0\')+\'</span> \' : \'\';\n      tips[ti] = seqPrefix+\'<strong>\'+sp.label.replace(/</g,\'&lt;\')+\'</strong>\'+snippetLine+\'<br>\'+fmtTime(sp.start_s)+\' \u{2192} \'+fmtTime(sp.end_s)+\' (\'+dur+\'s)\'+tipExtra+modelLine;\n      tiToSeq[ti] = sp.seq;\n      tiToSpan[ti] = sp;\n      const seqAttr = (sp.seq != null) ? \' data-seq=\"\'+sp.seq+\'\"\' : \'\';\n      const editable = EDITABLE_LAYERS[key];\n      const isDialogue = key === \'dialogue\';\n      const eAttr = editable ? \' data-effect-key=\"\'+escAttr(sp.label)+\'\" data-layer=\"\'+key+\'\"\'\n                  : (isDialogue ? \' data-layer=\"dialogue\"\' : \'\');\n      if (editable) { tips[ti] += \'<br><span style=\"opacity:0.4;font-size:0.85em\">\u{270e} double-click to edit sound profile</span>\'; }\n      else if (isDialogue) { tips[ti] += \'<br><span style=\"opacity:0.4;font-size:0.85em\">\u{270e} double-click to edit dialogue</span>\'; }\n      lhtml += \'<div class=\"span \'+COLORS[key]+\'\" style=\"left:\'+left+\'%;width:\'+w+\'%\" data-ti=\"\'+ti+\'\"\'+seqAttr+eAttr+\'>\'+rampBadges+\'</div>\';\n      ti++;\n    }\n    lhtml += \'</div></div>\';\n  }\n  document.getElementById(\'layers\').innerHTML = lhtml;\n  document.getElementById(\'zoom-info\').textContent = Math.round(zoom*100) + \'%\';\n}\n\n// Floating tooltip \u{2014} uses position:fixed to escape overflow clipping\n(function() {\n  const tip = document.getElementById(\'floattip\');\n  document.addEventListener(\'mouseover\', function(e) {\n    const sp = e.target.closest(\'.span[data-ti]\');\n    if (sp) {\n      tip.innerHTML = tips[sp.dataset.ti] || \'\';\n      tip.style.display = \'block\';\n    }\n  });\n  document.addEventListener(\'mouseout\', function(e) {\n    if (!e.relatedTarget || !e.relatedTarget.closest(\'.span[data-ti]\')) {\n      tip.style.display = \'none\';\n    }\n  });\n  document.addEventListener(\'mousemove\', function(e) {\n    if (tip.style.display === \'block\') {\n      let x = e.clientX + 14, y = e.clientY - 10;\n      if (x + tip.offsetWidth > window.innerWidth - 8) x = e.clientX - tip.offsetWidth - 14;\n      if (y + tip.offsetHeight > window.innerHeight - 8) y = e.clientY - tip.offsetHeight - 10;\n      tip.style.left = x + \'px\';\n      tip.style.top = y + \'px\';\n    }\n  });\n})();\n\nfunction zoomIn() { zoom = Math.min(zoom * 1.5, 20); render(); }\nfunction zoomOut() { zoom = Math.max(zoom / 1.5, 0.5); render(); }\nfunction zoomReset() { zoom = 1; render(); }\n\ndocument.getElementById(\'tc\').addEventListener(\'wheel\', function(e) {\n  if (e.ctrlKey || e.metaKey) {\n    e.preventDefault();\n    if (e.deltaY < 0) zoomIn(); else zoomOut();\n  }\n}, {passive: false});\n\nrender();\n\n// === NAS-aware audio prefetch: fetch-to-Blob with byte progress ===\n// Workspace reads may cross a NAS mount; streaming <audio> from\n// /gradio_api/file= gives zero feedback while gigabytes trickle in.\nconst _blobCache = {};  // full URL (incl. ?v= cache-buster) -> object URL\nlet _playToken = 0;     // bumped on each play intent; stale awaits bail\n\nfunction fmtMB(bytes) {\n  return Math.round(bytes / 1048576) + \' MB\';\n}\n\nasync function fetchAudioBlob(url, onProgress, signal) {\n  if (_blobCache[url]) return _blobCache[url];\n  const resp = await fetch(url, {signal: signal});\n  if (!resp.ok) throw new Error(\'HTTP \' + resp.status);\n  const total = parseInt(resp.headers.get(\'Content-Length\') || \'0\', 10);\n  const reader = resp.body.getReader();\n  const chunks = [];\n  let received = 0;\n  for (;;) {\n    const r = await reader.read();\n    if (r.done) break;\n    chunks.push(r.value);\n    received += r.value.length;\n    if (onProgress) onProgress(received, total);\n  }\n  const objUrl = URL.createObjectURL(new Blob(chunks));\n  _blobCache[url] = objUrl;\n  return objUrl;\n}\n\n\n// === Right-click Sound Profile Editor ===\nfunction _sfxField(id, label, min, max, step, val, pfx, defs, help, disabled) {\n  const catDef = defs[pfx + id];\n  const globDef = defs[id];\n  const ph = catDef != null ? \'category default: \' + catDef\n           : (globDef != null ? \'global default: \' + globDef : \'no default set\');\n  const va = (val != null) ? \' value=\"\' + val + \'\"\' : \'\';\n  const da = disabled ? \' disabled\' : \'\';\n  return \'<div class=\"sfx-field-wrap\"><label>\' + label + \'</label>\' +\n    \'<input type=\"number\" id=\"sfxf-\' + id + \'\" min=\"\' + min + \'\" max=\"\' + max +\n    \'\" step=\"\' + step + \'\"\' + va + \' placeholder=\"\' + ph + \'\"\' + da + \'>\' +\n    \'<div class=\"sfx-help\">\' + help + \'</div></div>\';\n}\n\n// Category-defaults fields (ids sfxd-*): unlike _sfxField\'s per-cue fields\n// (where the category default is only a placeholder for inherited values),\n// here the category default IS the value being edited -- pre-filled from\n// defaults[layer_id] when set, falling back to the un-prefixed global\n// default as placeholder text when it isn\'t.\nfunction _sfxDefaultsField(id, label, min, max, step, layer, defaults, help) {\n  const val = defaults[layer + \'_\' + id];\n  const glob = defaults[id];\n  const ph = glob != null ? \'global default: \' + glob : \'no default set\';\n  const va = (val != null) ? \' value=\"\' + val + \'\"\' : \'\';\n  return \'<div class=\"sfx-field-wrap\"><label>\' + label + \'</label>\' +\n    \'<input type=\"number\" id=\"sfxd-\' + id + \'\" min=\"\' + min + \'\" max=\"\' + max +\n    \'\" step=\"\' + step + \'\"\' + va + \' placeholder=\"\' + ph + \'\">\' +\n    \'<div class=\"sfx-help\">\' + help + \'</div></div>\';\n}\n\nlet _sfxModalCtx = null;  // {key, layer, effect, defaults, naturalS} of the open editor\n\nfunction _showSfxModal(key, layer, effect, defaults, naturalS) {\n  _sfxModalCtx = {key: key, layer: layer, effect: effect, defaults: defaults,\n                  naturalS: (naturalS != null ? naturalS : null)};\n  const isAmb = layer === \'ambience\';\n  const pfx = layer + \'_\';\n  document.getElementById(\'sfx-modal-title\').textContent = key;\n  document.getElementById(\'sfx-modal-fields\').innerHTML =\n    _sfxField(\'volume_percentage\', \'Volume %\', 0, 200, 1,\n      effect.volume_percentage ?? null, pfx, defaults,\n      \'Per-cue override (0\u{2013}200, 100\u{a0}=\u{a0}unity). Clear to inherit \' + pfx + \'volume_percentage default.\', false) +\n    _sfxField(\'ramp_in_seconds\', \'Ramp In (s)\', 0, 30, 0.1,\n      effect.ramp_in_seconds ?? null, pfx, defaults,\n      \'Fade-in duration in seconds. Clear to inherit \' + pfx + \'ramp_in_seconds default.\', false) +\n    _sfxField(\'ramp_out_seconds\', \'Ramp Out (s)\', 0, 30, 0.1,\n      effect.ramp_out_seconds ?? null, pfx, defaults,\n      \'Fade-out duration in seconds. Clear to inherit \' + pfx + \'ramp_out_seconds default.\', false) +\n    _sfxField(\'play_duration\', \'Play Duration %\', 0, 100, 1,\n      effect.play_duration ?? null, pfx, defaults,\n      \'Percentage of clip to play (0\u{2013}100). Not applicable to AMBIENCE.\', isAmb);\n  document.getElementById(\'sfx-defaults-summary\').textContent = \'Category defaults (\' + layer + \')\';\n  const defHelp = \'Applies to every \' + layer.toUpperCase() + \' cue without its own override.\';\n  document.getElementById(\'sfx-defaults-warning\').textContent = defHelp;\n  document.getElementById(\'sfx-defaults-fields\').innerHTML =\n    _sfxDefaultsField(\'volume_percentage\', \'Volume %\', 0, 200, 1, layer, defaults, defHelp) +\n    _sfxDefaultsField(\'ramp_in_seconds\', \'Ramp In (s)\', 0, 30, 0.1, layer, defaults, defHelp) +\n    _sfxDefaultsField(\'ramp_out_seconds\', \'Ramp Out (s)\', 0, 30, 0.1, layer, defaults, defHelp);\n  const modal = document.getElementById(\'sfx-modal\');\n  modal.dataset.effectKey = key;\n  modal.dataset.layer = layer;\n  const st = document.getElementById(\'sfx-modal-status\');\n  st.textContent = \'\';\n  st.style.color = \'#aaa\';\n  document.getElementById(\'sfx-modal-save\').disabled = false;\n  document.getElementById(\'sfx-modal-overlay\').style.display = \'block\';\n}\n\ndocument.addEventListener(\'dblclick\', function(e) {\n  const span = e.target.closest(\'.span[data-effect-key]\');\n  if (!span) return;\n  e.stopPropagation();\n  clearTimeout(clickTimer);  // cancel the deferred single-click preview\n  const key = span.dataset.effectKey;\n  const layer = span.dataset.layer;\n  fetch(\'/xil/get-sfx?slug=\' + encodeURIComponent(XIL_SLUG) +\n        \'&tag=\' + encodeURIComponent(XIL_TAG) +\n        \'&key=\' + encodeURIComponent(key))\n    .then(function(r) { return r.json(); })\n    .then(function(d) { _showSfxModal(key, layer, d.effect || {}, d.defaults || {}, d.natural_s); })\n    .catch(function(err) { console.error(\'sfx-modal:\', err); });\n});\n\ndocument.getElementById(\'sfx-modal-cancel\').addEventListener(\'click\', function() {\n  document.getElementById(\'sfx-modal-overlay\').style.display = \'none\';\n});\ndocument.getElementById(\'sfx-modal-overlay\').addEventListener(\'click\', function(e) {\n  if (e.target === this) this.style.display = \'none\';\n});\n// Push a saved edit into the in-page span data so the next click-to-play\n// preview reflects it immediately, without waiting for a daw regen.\nfunction _applySfxEditToSpans(payload) {\n  if (!_sfxModalCtx) return;\n  const layer = _sfxModalCtx.layer, defaults = _sfxModalCtx.defaults;\n  function eff(field) {\n    const v = payload[field];\n    if (v != null) return v;\n    const cat = defaults[layer + \'_\' + field];\n    return cat != null ? cat : (defaults[field] != null ? defaults[field] : null);\n  }\n  const spans = (DATA.layers[layer] || []).filter(function(sp) { return sp.label === payload.key; });\n  const durS = _sfxModalCtx.effect && _sfxModalCtx.effect.duration_seconds;\n  const natS = _sfxModalCtx.naturalS;\n  // Mirror mix_common.collect_stem_plans: play_duration wins over\n  // duration_seconds, and BOTH are relative to the source file\'s own length \u{2014}\n  // not to duration_seconds.  Basing the preview on duration_seconds made\n  // \"Play Duration % = 100\" look like it changed nothing on exactly the cues\n  // that duration_seconds was clipping, inviting the user to undo a correct edit.\n  function _previewLength(pd) {\n    if (natS) {\n      if (pd != null) return natS * pd / 100;\n      if (durS) return Math.min(durS, natS);\n      return natS;\n    }\n    // No source file (generated cue) or unreadable \u{2014} duration_seconds IS the\n    // rendered length, so the old arithmetic is the right fallback.\n    if (durS) return durS * (pd != null ? pd : 100) / 100;\n    return null;\n  }\n  spans.forEach(function(sp) {\n    sp.volume_pct = eff(\'volume_percentage\');\n    sp.ramp_in_s = eff(\'ramp_in_seconds\');\n    sp.ramp_out_s = eff(\'ramp_out_seconds\');\n    if (layer !== \'ambience\') {\n      sp.play_duration = payload.play_duration;\n      const len = _previewLength(payload.play_duration);\n      if (len != null) sp.end_s = sp.start_s + len;\n    }\n  });\n}\n\ndocument.getElementById(\'sfx-modal-save\').addEventListener(\'click\', function() {\n  const modal = document.getElementById(\'sfx-modal\');\n  function getVal(id) {\n    const el = document.getElementById(\'sfxf-\' + id);\n    if (!el || el.disabled) return null;\n    const v = el.value.trim();\n    return v === \'\' ? null : parseFloat(v);\n  }\n  function getDefaultsVal(id) {\n    const el = document.getElementById(\'sfxd-\' + id);\n    if (!el) return null;\n    const v = el.value.trim();\n    return v === \'\' ? null : parseFloat(v);\n  }\n  const layer = modal.dataset.layer;\n  const DEFAULTS_FIELDS = [\'volume_percentage\', \'ramp_in_seconds\', \'ramp_out_seconds\'];\n  const defaultsPayload = {slug: XIL_SLUG, tag: XIL_TAG, layer: layer};\n  DEFAULTS_FIELDS.forEach(function(f) { defaultsPayload[f] = getDefaultsVal(f); });\n  const loadedDefaults = (_sfxModalCtx && _sfxModalCtx.defaults) || {};\n  const defaultsChanged = DEFAULTS_FIELDS.some(function(f) {\n    const loaded = loadedDefaults[layer + \'_\' + f];\n    return defaultsPayload[f] !== (loaded == null ? null : loaded);\n  });\n  const payload = {\n    slug: XIL_SLUG, tag: XIL_TAG, key: modal.dataset.effectKey,\n    volume_percentage: getVal(\'volume_percentage\'),\n    ramp_in_seconds: getVal(\'ramp_in_seconds\'),\n    ramp_out_seconds: getVal(\'ramp_out_seconds\'),\n    play_duration: getVal(\'play_duration\'),\n  };\n  const st = document.getElementById(\'sfx-modal-status\');\n  const btn = this;\n  st.style.color = \'#aaa\';\n  st.textContent = \'Saving\u{2026}\';\n  btn.disabled = true;\n  function savePerCue() {\n    return fetch(\'/xil/update-sfx\', {\n      method: \'POST\',\n      headers: {\'Content-Type\': \'application/json\'},\n      body: JSON.stringify(payload),\n    }).then(function(r) { return r.json(); });\n  }\n  const defaultsStep = defaultsChanged\n    ? fetch(\'/xil/update-sfx-defaults\', {\n        method: \'POST\',\n        headers: {\'Content-Type\': \'application/json\'},\n        body: JSON.stringify(defaultsPayload),\n      }).then(function(r) { return r.json(); })\n    : Promise.resolve({ok: true});\n  defaultsStep\n  .then(function(dDef) {\n    if (!dDef.ok) {\n      st.style.color = \'#ef5350\';\n      st.textContent = \'Error: \' + (dDef.error || \'Defaults save failed.\');\n      btn.disabled = false;\n      return null;\n    }\n    if (defaultsChanged) {\n      loadedDefaults[layer + \'_volume_percentage\'] = defaultsPayload.volume_percentage;\n      loadedDefaults[layer + \'_ramp_in_seconds\'] = defaultsPayload.ramp_in_seconds;\n      loadedDefaults[layer + \'_ramp_out_seconds\'] = defaultsPayload.ramp_out_seconds;\n    }\n    return savePerCue();\n  })\n  .then(function(d) {\n    if (!d) return;\n    if (d.ok) {\n      _applySfxEditToSpans(payload);\n      st.style.color = \'#4caf50\';\n      st.textContent = d.message || \'Saved.\';\n      setTimeout(function() {\n        document.getElementById(\'sfx-modal-overlay\').style.display = \'none\';\n        btn.disabled = false;\n      }, 1800);\n    } else {\n      st.style.color = \'#ef5350\';\n      st.textContent = \'Error: \' + (d.error || \'Save failed.\');\n      btn.disabled = false;\n    }\n  })\n  .catch(function(err) {\n    st.style.color = \'#ef5350\';\n    st.textContent = \'Network error: \' + err.message;\n    btn.disabled = false;\n  });\n});\n\n// === Dialogue-line editor ===\n// A second, independent double-click editor for the Dialogue layer only \u{2014}\n// separate from the sound-profile editor above because it identifies its\n// target by seq (one specific script line) rather than by effect key (one\n// sound cue, possibly backing several spans).\nlet _dlgModalSeq = null;\n\nfunction _showDlgModal(seq, speaker, text, speakers) {\n  _dlgModalSeq = seq;\n  document.getElementById(\'dlg-modal-title\').textContent = \'Dialogue \u{2014} seq \' + seq;\n  const speakerField = speakers.length\n    ? \'<div class=\"dlg-field-wrap\"><label>Speaker</label><select id=\"dlgf-speaker\">\' +\n      speakers.map(function(s) {\n        return \'<option value=\"\' + escAttr(s.key) + \'\"\' + (s.key === speaker ? \' selected\' : \'\') +\n               \'>\' + escAttr(s.label) + \'</option>\';\n      }).join(\'\') + \'</select></div>\'\n    : \'<div class=\"dlg-field-wrap\"><label>Speaker key</label>\' +\n      \'<input type=\"text\" id=\"dlgf-speaker\" value=\"\' + escAttr(speaker) + \'\"></div>\';\n  document.getElementById(\'dlg-modal-fields\').innerHTML = speakerField +\n    \'<div class=\"dlg-field-wrap\"><label>Text</label><textarea id=\"dlgf-text\">\' + escAttr(text) + \'</textarea></div>\';\n  const st = document.getElementById(\'dlg-modal-status\');\n  st.textContent = \'\';\n  st.style.color = \'#aaa\';\n  document.getElementById(\'dlg-modal-save\').disabled = false;\n  document.getElementById(\'dlg-modal-overlay\').style.display = \'block\';\n}\n\ndocument.addEventListener(\'dblclick\', function(e) {\n  const span = e.target.closest(\'.span[data-layer=\"dialogue\"]\');\n  if (!span) return;\n  e.stopPropagation();\n  clearTimeout(clickTimer);  // cancel the deferred single-click preview\n  const seq = parseInt(span.dataset.seq, 10);\n  fetch(\'/xil/get-parsed-entry?slug=\' + encodeURIComponent(XIL_SLUG) +\n        \'&tag=\' + encodeURIComponent(XIL_TAG) +\n        \'&seq=\' + encodeURIComponent(seq))\n    .then(function(r) { return r.json(); })\n    .then(function(d) {\n      if (d.error) { console.error(\'dialogue-modal:\', d.error); return; }\n      _showDlgModal(seq, d.speaker || \'\', d.text || \'\', d.speakers || []);\n    })\n    .catch(function(err) { console.error(\'dialogue-modal:\', err); });\n});\n\ndocument.getElementById(\'dlg-modal-cancel\').addEventListener(\'click\', function() {\n  document.getElementById(\'dlg-modal-overlay\').style.display = \'none\';\n});\ndocument.getElementById(\'dlg-modal-overlay\').addEventListener(\'click\', function(e) {\n  if (e.target === this) this.style.display = \'none\';\n});\n\n// A saved edit patches DATA and re-renders, so the tooltip\'s snippet reflects\n// it immediately \u{2014} the full timeline (durations, TTS model line, etc.) still\n// needs the next `xil daw --timeline-html` regen, same pre-existing limit\n// every other edit made outside this page already has.\nfunction _applyDialogueEditToSpans(seq, speaker, text) {\n  const snippet = text.length > 60 ? text.slice(0, 60) : text;\n  (DATA.layers.dialogue || []).forEach(function(sp) {\n    if (sp.seq === seq) {\n      sp.snippet = snippet;\n      sp.label = speaker;\n    }\n  });\n  render();\n}\n\ndocument.getElementById(\'dlg-modal-save\').addEventListener(\'click\', function() {\n  const speakerEl = document.getElementById(\'dlgf-speaker\');\n  const textEl = document.getElementById(\'dlgf-text\');\n  const payload = {\n    slug: XIL_SLUG, tag: XIL_TAG, seq: _dlgModalSeq,\n    speaker: speakerEl.value.trim(), text: textEl.value.trim(),\n  };\n  const st = document.getElementById(\'dlg-modal-status\');\n  const btn = this;\n  st.style.color = \'#aaa\';\n  st.textContent = \'Saving\u{2026}\';\n  btn.disabled = true;\n  fetch(\'/xil/update-parsed-entry\', {\n    method: \'POST\',\n    headers: {\'Content-Type\': \'application/json\'},\n    body: JSON.stringify(payload),\n  })\n  .then(function(r) { return r.json(); })\n  .then(function(d) {\n    if (d.ok) {\n      _applyDialogueEditToSpans(payload.seq, payload.speaker, payload.text);\n      st.style.color = \'#4caf50\';\n      st.textContent = d.message || \'Saved.\';\n      setTimeout(function() {\n        document.getElementById(\'dlg-modal-overlay\').style.display = \'none\';\n        btn.disabled = false;\n      }, 1800);\n    } else {\n      st.style.color = \'#ef5350\';\n      st.textContent = \'Error: \' + (d.error || \'Save failed.\');\n      btn.disabled = false;\n    }\n  })\n  .catch(function(err) {\n    st.style.color = \'#ef5350\';\n    st.textContent = \'Network error: \' + err.message;\n    btn.disabled = false;\n  });\n});\n\n\n// === Preview playback with sound profile ===\n// Web Audio gain graph: audio.volume caps at 1.0, so volumes >100% and\n// scheduled ramps require a GainNode.  A media element can only be\n// connected to a context once \u{2014} create lazily on first play and reuse.\nlet audioCtx = null, gainNode = null, stopTimer = null, clickTimer = null;\nfunction ensureAudioGraph() {\n  if (audioCtx) return;\n  audioCtx = new (window.AudioContext || window.webkitAudioContext)();\n  const src = audioCtx.createMediaElementSource(document.getElementById(\'audio-el\'));\n  gainNode = audioCtx.createGain();\n  src.connect(gainNode);\n  gainNode.connect(audioCtx.destination);\n}\n\nasync function playSpan(el) {\n  const seq = el.dataset.seq;\n  const fp = CLIPS[seq];\n  if (!fp) return;\n  const token = ++_playToken;\n  document.querySelectorAll(\'.span.playing\').forEach(function(s) { s.classList.remove(\'playing\'); });\n  document.querySelectorAll(\'.span.loading\').forEach(function(s) { s.classList.remove(\'loading\'); });\n  const audioEl = document.getElementById(\'audio-el\');\n  const ti = el.dataset.ti;\n  const rawLabel = ti != null ? (tips[ti] || \'\').replace(/<[^>]+>/g, \' \').replace(/\\s+/g, \' \').trim() : seq;\n  const label = document.getElementById(\'player-label\');\n  // .active first \u{2014} the player strip is display:none until then, and the\n  // loading readout must be visible while the clip crosses the NAS.\n  document.getElementById(\'xil-player\').classList.add(\'active\');\n  el.classList.add(\'loading\');\n  label.textContent = \'Loading\\u2026\';\n  let src;\n  try {\n    src = await fetchAudioBlob(fp, function(received, total) {\n      label.textContent = total\n        ? \'Loading\\u2026 \' + Math.round(received / total * 100) + \'%\'\n        : \'Loading\\u2026 \' + fmtMB(received);\n    });\n  } catch (err) {\n    el.classList.remove(\'loading\');\n    label.textContent = \'\\u26a0 \' + (err && err.message ? err.message : err);\n    return;\n  }\n  el.classList.remove(\'loading\');\n  if (token !== _playToken) return;  // user clicked elsewhere mid-load\n  if (mixPlaying) pauseMix();  // mutual exclusion with the full-mix transport\n  el.classList.add(\'playing\');\n  label.textContent = rawLabel;\n  audioEl.src = src;\n\n  ensureAudioGraph();\n  if (audioCtx.state === \'suspended\') audioCtx.resume();\n  clearTimeout(stopTimer);\n  const sp = ti != null ? tiToSpan[ti] : null;\n  const g = gainNode.gain;\n  const t0 = audioCtx.currentTime;\n  g.cancelScheduledValues(t0);\n  const target = (sp && sp.volume_pct != null) ? sp.volume_pct / 100 : 1;\n  const ri = (sp && sp.ramp_in_s) || 0;\n  const ro = (sp && sp.ramp_out_s) || 0;\n  // end_s - start_s is already the play_duration-trimmed clip length\n  const playLen = sp ? Math.max(sp.end_s - sp.start_s, 0.1) : 0;\n  if (ri > 0) {\n    g.setValueAtTime(0, t0);\n    g.linearRampToValueAtTime(target, t0 + ri);\n  } else {\n    g.setValueAtTime(target, t0);\n  }\n  if (sp && ro > 0 && playLen > ro) {\n    g.setValueAtTime(target, t0 + playLen - ro);\n    g.linearRampToValueAtTime(0, t0 + playLen);\n  }\n  audioEl.play();\n  if (sp) {\n    stopTimer = setTimeout(function() { audioEl.pause(); }, playLen * 1000);\n  }\n}\n\ndocument.getElementById(\'layers\').addEventListener(\'click\', function(e) {\n  if (e.detail > 1) return;  // ignore clicks that are part of a double-click\n  const el = e.target.closest(\'.span[data-seq]\');\n  if (!el) return;\n  // Defer 250ms so a double-click (edit) can cancel the preview.\n  clearTimeout(clickTimer);\n  clickTimer = setTimeout(function() { playSpan(el); }, 250);\n});\n\n// === DAW-style transport: synced full-mix playback + playhead ===\nconst MIX_ORDER = [\'dialogue\',\'sfx\',\'music\',\'ambience\',\'vintage_filter\'];\nlet mixEls = null, mixMaster = null, mixPlaying = false;\nlet mixState = \'idle\';   // idle -> loading -> ready\nlet mixAbort = null;     // AbortController for the in-flight prefetch\nconst mixMuted = {};     // layer -> muted?  (recorded even before the mix is built)\nlet phRaf = null, syncIv = null;\n\nfunction hideMixLoading() {\n  const box = document.getElementById(\'mix-loading\');\n  box.classList.remove(\'visible\');\n  box.classList.remove(\'error\');\n}\n\nfunction showMixError(msg) {\n  const box = document.getElementById(\'mix-loading\');\n  box.classList.add(\'visible\');\n  box.classList.add(\'error\');\n  document.getElementById(\'mix-loading-text\').textContent = msg;\n  document.getElementById(\'mix-loading-layers\').innerHTML = \'\';\n}\n\n// Prefetch all layer WAVs to Blobs with an aggregate byte-progress readout.\n// Resolves true once the mix elements exist; false when idle-with-no-audio,\n// already loading (caller should treat the click as a cancel), or aborted.\nasync function ensureMix() {\n  if (mixState === \'ready\') return true;\n  if (mixState === \'loading\') return false;\n  const keys = MIX_ORDER.filter(function(k) { return LAYER_AUDIO[k]; });\n  if (!keys.length) return false;\n  mixState = \'loading\';\n  mixAbort = new AbortController();\n  const prog = {};\n  keys.forEach(function(k) { prog[k] = {received: 0, total: 0}; });\n  const box = document.getElementById(\'mix-loading\');\n  box.classList.add(\'visible\');\n  box.classList.remove(\'error\');\n  document.getElementById(\'mix-loading-text\').textContent = \'Loading audio\\u2026\';\n  document.getElementById(\'mix-loading-layers\').innerHTML = \'\';\n  document.getElementById(\'transport-play\').innerHTML = \'&#8987;\';\n  function repaint() {\n    let recv = 0, total = 0, allKnown = true;\n    const rows = [];\n    for (const k of keys) {\n      recv += prog[k].received;\n      total += prog[k].total;\n      if (!prog[k].total) allKnown = false;\n      rows.push(\'<span>\' + LABELS[k] + \' \' + fmtMB(prog[k].received) +\n                (prog[k].total ? \' / \' + fmtMB(prog[k].total) : \'\') + \'</span>\');\n    }\n    let txt = \'Loading audio\\u2026 \';\n    if (allKnown && total) {\n      txt += Math.round(recv / total * 100) + \'% (\' + fmtMB(recv) + \' / \' + fmtMB(total) + \')\';\n    } else {\n      txt += fmtMB(recv);\n    }\n    document.getElementById(\'mix-loading-text\').textContent = txt;\n    document.getElementById(\'mix-loading-layers\').innerHTML = rows.join(\'\');\n  }\n  try {\n    const urls = await Promise.all(keys.map(function(k) {\n      return fetchAudioBlob(LAYER_AUDIO[k], function(received, total) {\n        prog[k].received = received;\n        prog[k].total = total;\n        repaint();\n      }, mixAbort.signal);\n    }));\n    mixEls = {};\n    keys.forEach(function(k, i) {\n      const a = new Audio(urls[i]);\n      a.preload = \'auto\';\n      a.muted = !!mixMuted[k];\n      mixEls[k] = a;\n    });\n    mixMaster = mixEls[\'dialogue\'] || mixEls[keys[0]];\n    mixMaster.addEventListener(\'ended\', pauseMix);\n    mixState = \'ready\';\n    hideMixLoading();\n    document.getElementById(\'transport-play\').innerHTML = \'&#9205;\';\n    return true;\n  } catch (err) {\n    mixState = \'idle\';\n    mixEls = null;\n    mixMaster = null;\n    document.getElementById(\'transport-play\').innerHTML = \'&#9205;\';\n    if (err && err.name === \'AbortError\') {\n      hideMixLoading();\n    } else {\n      showMixError(\'Load failed: \' + (err && err.message ? err.message : err));\n    }\n    return false;\n  }\n}\n\nasync function playMix() {\n  if (mixState === \'loading\') { mixAbort.abort(); return; }  // toggle = cancel\n  const token = ++_playToken;\n  if (!await ensureMix()) return;\n  if (token !== _playToken) return;  // user clicked a clip mid-load\n  if (!mixMaster) return;\n  // Mutual exclusion: stop the single-stem preview\n  clearTimeout(clickTimer);\n  clearTimeout(stopTimer);\n  document.getElementById(\'audio-el\').pause();\n  const t = mixMaster.currentTime;\n  for (const k in mixEls) {\n    if (mixEls[k] !== mixMaster) mixEls[k].currentTime = t;\n    mixEls[k].play();\n  }\n  mixPlaying = true;\n  document.getElementById(\'transport-play\').innerHTML = \'&#9208;\';\n  document.getElementById(\'playhead\').style.display = \'block\';\n  phTick();\n  syncIv = setInterval(syncSlaves, 1000);\n}\n\nfunction pauseMix() {\n  if (!mixEls) return;\n  for (const k in mixEls) mixEls[k].pause();\n  mixPlaying = false;\n  document.getElementById(\'transport-play\').innerHTML = \'&#9205;\';\n  cancelAnimationFrame(phRaf);\n  clearInterval(syncIv);\n}\n\nfunction syncSlaves() {\n  const t = mixMaster.currentTime;\n  for (const k in mixEls) {\n    const el = mixEls[k];\n    if (el !== mixMaster && Math.abs(el.currentTime - t) > 0.06) el.currentTime = t;\n  }\n}\n\nfunction phTick() {\n  if (!mixMaster) return;\n  const W = BASE_WIDTH * zoom;\n  const t = mixMaster.currentTime;\n  const phx = 90 + t / TOTAL * W;\n  document.getElementById(\'playhead\').style.left = phx + \'px\';\n  document.getElementById(\'transport-time\').textContent = fmtTime(t) + \' / \' + fmtTime(TOTAL);\n  if (mixPlaying) {\n    // keep the playhead visible while zoomed in\n    const tc = document.getElementById(\'tc\');\n    if (phx < tc.scrollLeft + 90 || phx > tc.scrollLeft + tc.clientWidth - 40) {\n      tc.scrollLeft = Math.max(phx - 120, 0);\n    }\n    phRaf = requestAnimationFrame(phTick);\n  }\n}\n\nasync function seekTo(t) {\n  if (!await ensureMix()) return;\n  if (!mixMaster) return;\n  t = Math.max(0, Math.min(t, TOTAL));\n  for (const k in mixEls) mixEls[k].currentTime = t;\n  document.getElementById(\'playhead\').style.display = \'block\';\n  phTick();\n}\n\ndocument.getElementById(\'transport-play\').addEventListener(\'click\', function() {\n  if (mixPlaying) pauseMix(); else playMix();\n});\n\n// Return to start (accessibility): seekTo(0) preserves play/pause state \u{2014}\n// keeps playing if playing, stays paused if paused.\ndocument.getElementById(\'transport-restart\').addEventListener(\'click\', function() {\n  seekTo(0);\n});\n\ndocument.addEventListener(\'keydown\', function(e) {\n  // Home = return to start. Ignore while typing or when a modal is open.\n  const tag = (e.target && e.target.tagName) || \'\';\n  if (tag === \'INPUT\' || tag === \'TEXTAREA\') return;\n  const open = function(id) {\n    const m = document.getElementById(id);\n    return m && m.style.display !== \'none\' && m.style.display !== \'\';\n  };\n  if (open(\'sfx-modal-overlay\') || open(\'dlg-modal-overlay\')) return;\n  if (e.key === \'Home\') { e.preventDefault(); seekTo(0); }\n});\n\ndocument.getElementById(\'ruler\').addEventListener(\'click\', function(e) {\n  const rect = this.getBoundingClientRect();\n  const x = e.clientX - rect.left - 90;\n  if (x < 0) return;\n  seekTo(x / (BASE_WIDTH * zoom) * TOTAL);\n});\n\ndocument.getElementById(\'mix-loading-cancel\').addEventListener(\'click\', function() {\n  if (mixState === \'loading\' && mixAbort) mixAbort.abort();\n  else hideMixLoading();  // dismiss a lingering error state\n});\n\n// Record mute preference without triggering the prefetch \u{2014} a pre-load\n// toggle must not start a multi-GB download over the NAS.\ndocument.querySelectorAll(\'.mute-toggle input\').forEach(function(cb) {\n  if (!LAYER_AUDIO[cb.dataset.layer]) cb.disabled = true;\n  cb.addEventListener(\'change\', function() {\n    mixMuted[this.dataset.layer] = this.checked;\n    if (mixEls && mixEls[this.dataset.layer]) mixEls[this.dataset.layer].muted = this.checked;\n  });\n});\n\nif (!Object.keys(LAYER_AUDIO).length) {\n  document.getElementById(\'transport-restart\').style.display = \'none\';\n  document.getElementById(\'transport-play\').style.display = \'none\';\n  document.getElementById(\'transport-time\').style.display = \'none\';\n  document.getElementById(\'transport-mutes\').style.display = \'none\';\n  document.getElementById(\'transport-hint\').style.display = \'inline\';\n} else {\n  document.getElementById(\'transport-time\').textContent = \'0:00 / \' + fmtTime(TOTAL);\n}\n\n</script>\n</body>\n</html>\n";