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@@ · @@XIL_SPAN_COUNT@@ assets across 5 layers · Generated @@XIL_GENERATED_AT@@</p>\n<div class=\"controls\">\n <button onclick=\"zoomIn()\">Zoom +</button>\n <button onclick=\"zoomOut()\">Zoom −</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\">⏮</button>\n <button id=\"transport-play\" title=\"Play/pause full mix\" aria-label=\"Play/pause full mix\">⏵</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…</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,\'&\').replace(/\"/g,\'"\').replace(/</g,\'<\'); }\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,\'<\')+\'\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,\'<\')+\'</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 = \'⌛\';\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 = \'⏵\';\n return true;\n } catch (err) {\n mixState = \'idle\';\n mixEls = null;\n mixMaster = null;\n document.getElementById(\'transport-play\').innerHTML = \'⏵\';\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 = \'⏸\';\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 = \'⏵\';\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";