renderer.jsjavascript
/**
 * @file Renderer process for EZLaybacks. All UI logic lives here: video and track
 * import, real-time Web Audio preview (per-track GainNodes, tail fades), waveform
 * rendering and playhead animation, trim handles, the Music Start marker, auto-mix
 * gain staging, the Save Laybacks modal and export IPC, project save/restore, and
 * the auth gate. Talks to the main process only through the window.ezl preload bridge.
 * @module renderer
 */

/** MARK: ROOT FLAGS */

/**
 * Master toggle for the footer status readout. Hidden for now — errors surface via
 * an overt popup (showError) instead. Flip to true to show the inline status line again.
 * @type {boolean}
 */
const SHOW_STATUS_TEXT = false;

/** MARK: AUDIO CONTEXT */

let _audioCtx = null;

/**
 * Return the shared AudioContext, creating a fresh one if none exists or the
 * previous one was closed.
 * @returns {AudioContext} The live shared context.
 */
function getAudioCtx() {
  if (!_audioCtx || _audioCtx.state === 'closed') _audioCtx = new AudioContext();
  return _audioCtx;
}

/** MARK: PEAK EXTRACTION */

/**
 * Decode any blob URL to an AudioBuffer. Web Audio's decodeAudioData handles a broader
 * set of formats than the audio element — notably AIFF — which is why waveforms render
 * even for files the element refuses to play. decodeAudioData detaches the fetched
 * ArrayBuffer, so it must not be reused after the call.
 * @param {string} blobUrl - Object URL of the audio file to decode.
 * @returns {Promise<AudioBuffer>} The decoded buffer.
 */
async function decodeAudio(blobUrl) {
  const ctx = getAudioCtx();
  const resp = await fetch(blobUrl);
  const arrayBuf = await resp.arrayBuffer();
  return ctx.decodeAudioData(arrayBuf);
}

/**
 * Reduce a decoded buffer to numSamples normalized peak magnitudes for the waveform.
 * Peaks are normalized so the loudest one fills the canvas height.
 * @param {AudioBuffer} audioBuf - Decoded audio to summarize.
 * @param {number} [numSamples=1000] - Number of peak buckets to produce.
 * @returns {Float32Array} Normalized (0-1) peak magnitudes, one per bucket.
 */
function peaksFromBuffer(audioBuf, numSamples = 1000) {
  const channels = audioBuf.numberOfChannels;
  const length   = audioBuf.length;
  const block    = Math.max(1, Math.floor(length / numSamples));
  const peaks    = new Float32Array(numSamples);
  for (let i = 0; i < numSamples; i++) {
    let max = 0;
    const start = i * block;
    const end   = Math.min(start + block, length);
    for (let ch = 0; ch < channels; ch++) {
      const data = audioBuf.getChannelData(ch);
      for (let j = start; j < end; j++) {
        const abs = Math.abs(data[j]);
        if (abs > max) max = abs;
      }
    }
    peaks[i] = max;
  }
  const maxPeak = peaks.reduce((m, v) => v > m ? v : m, 0);
  if (maxPeak > 0) for (let i = 0; i < numSamples; i++) peaks[i] /= maxPeak;
  return peaks;
}

/** MARK: WAVEFORM DRAWING */

/**
 * Draw a static bar waveform onto a canvas from a peaks array, scaled for the
 * device pixel ratio.
 * @param {HTMLCanvasElement} canvas - Target canvas.
 * @param {Float32Array|number[]} peaks - Normalized peak magnitudes.
 * @param {string} color - Fill color for the bars.
 * @returns {void}
 */
function drawPeaks(canvas, peaks, color) {
  const dpr = window.devicePixelRatio || 1;
  const w = canvas.offsetWidth || 300;
  const h = canvas.offsetHeight || 28;
  canvas.width  = w * dpr;
  canvas.height = h * dpr;
  const ctx = canvas.getContext('2d');
  ctx.scale(dpr, dpr);
  const bw = 2, gap = 1;
  const bars = Math.floor(w / (bw + gap));
  ctx.fillStyle = color;
  for (let i = 0; i < bars; i++) {
    const pi = Math.round(i / bars * (peaks.length - 1));
    const bh = Math.max(1, peaks[pi] * h * 0.9);
    ctx.fillRect(i * (bw + gap), (h - bh) / 2, bw, bh);
  }
}

/**
 * Seeded pseudo-random number generator for deterministic placeholder waveforms.
 * @param {number} seed - Seed value; the same seed reproduces the same sequence.
 * @returns {function(): number} Function returning the next pseudo-random value in [0, 1).
 */
function rng(seed) { let s = seed; return () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; }

/**
 * Draw a seeded random placeholder waveform, shown while the real decode runs in
 * the background.
 * @param {HTMLCanvasElement} canvas - Target canvas.
 * @param {number} seed - RNG seed so the placeholder is stable across redraws.
 * @param {string} color - Fill color for the bars.
 * @returns {void}
 */
function drawWavePlaceholder(canvas, seed, color) {
  const dpr = window.devicePixelRatio || 1;
  const w = canvas.offsetWidth || 300;
  const h = canvas.offsetHeight || 28;
  canvas.width  = w * dpr;
  canvas.height = h * dpr;
  const ctx = canvas.getContext('2d');
  ctx.scale(dpr, dpr);
  const rand = rng(seed);
  const bw = 2, gap = 1;
  const bars = Math.floor(w / (bw + gap));
  let prev = 0.35;
  ctx.fillStyle = color;
  for (let i = 0; i < bars; i++) {
    const amp = prev * 0.55 + rand() * 0.45;
    prev = amp;
    const bh = Math.max(2, amp * h * 0.88);
    ctx.fillRect(i * (bw + gap), (h - bh) / 2, bw, bh);
  }
}

/**
 * Waveform bar color for a track card.
 * @param {boolean} active - Whether the card is the active track.
 * @returns {string} CSS color string.
 */
function getTrackColor(active) { return active ? '#3A6EBF' : '#283545'; }

/**
 * Redraw the OA waveform and every track card's waveform (real peaks where known,
 * placeholder otherwise). Bound to window load and resize.
 * @returns {void}
 */
function redrawAllWaveforms() {
  const vofxCanvas = document.getElementById('vofxWave');
  if (vofxPeaks) drawPeaks(vofxCanvas, vofxPeaks, '#234572');
  else drawWavePlaceholder(vofxCanvas, 101, '#1A2E48');
  document.querySelectorAll('.track-card').forEach(card => {
    const tw = card.querySelector('.tw');
    if (!tw) return;
    const id    = parseInt(card.dataset.trackId);
    const t     = trackMap.get(id);
    const color = getTrackColor(card.classList.contains('active'));
    if (t?.peaks) drawPeaks(tw, t.peaks, color);
    else drawWavePlaceholder(tw, parseInt(card.dataset.seed), color);
  });
}
window.addEventListener('load', redrawAllWaveforms);
window.addEventListener('resize', redrawAllWaveforms);

/** MARK: VOFX ANALYSIS — Original Audio waveform state and display helpers. */

let vofxPeaks = null;
let _currentVideoBlobUrl = null;

/**
 * Show the OA waveform once the video's loudness analysis returns. The peaks arrive
 * with the analysis, drawn from samples the Python worker already decoded — reading
 * the video in the renderer instead would pull the whole container into memory (past
 * ~2 GB arrayBuffer() throws, and a sync cut is routinely 1-5 GB). No-op when the
 * video was swapped while analysis ran.
 * @param {number[]} peaks - Normalized peak magnitudes from the worker.
 * @param {string} videoPath - Path the analysis ran for; identity guard against swaps.
 * @returns {void}
 */
function showVofxPeaks(peaks, videoPath) {
  if (currentVideoPath !== videoPath) return;
  setOaWaveform(peaks);
}

/**
 * Fit a replacement OA's peaks to the video timeline. The OA canvas spans the WHOLE
 * video; a replacement shorter than the video should occupy only its proportional
 * slice, with silence (a flat, zero-filled tail) filling the rest — not stretch to
 * fill, which misrepresents timing and slides the playhead off the audio. Resamples
 * the source peaks into the front srcDuration/videoDuration of a same-width buffer,
 * max-pooling each source bucket into its column so transient peaks survive the
 * squeeze. Returns the peaks unchanged when the source is as long as the video
 * (embedded OA, or an exact-length replacement) or the video duration isn't known yet.
 * @param {number[]|Float32Array} peaks - Source peak magnitudes.
 * @param {number} srcDurationSec - Duration of the source audio in seconds.
 * @returns {Float32Array} Peaks laid out on the video timeline.
 */
function oaDisplayPeaks(peaks, srcDurationSec) {
  const vid = videoEl.duration || 0;
  const src = Float32Array.from(peaks || []);
  if (!src.length || !vid || !srcDurationSec || srcDurationSec >= vid - 0.05) return src;
  const active = Math.max(1, Math.min(src.length, Math.round(src.length * srcDurationSec / vid)));
  const out = new Float32Array(src.length);
  for (let i = 0; i < active; i++) {
    const start = Math.floor(i / active * src.length);
    const end   = Math.max(start + 1, Math.floor((i + 1) / active * src.length));
    let m = 0;
    for (let j = start; j < end && j < src.length; j++) if (src[j] > m) m = src[j];
    out[i] = m;
  }
  return out;
}

/**
 * Paint the OA waveform from a peaks array. Shared by the embedded-OA analysis and
 * the OA replacement, which has no video-path identity to guard on — so the guard
 * lives in the caller. No-op on an empty array (a silent video keeps its placeholder).
 * Restarts the animation loop in case it exited before peaks were ready.
 * @param {number[]|Float32Array} peaks - Normalized peak magnitudes.
 * @returns {void}
 */
function setOaWaveform(peaks) {
  if (!peaks || !peaks.length) return;
  vofxPeaks = Float32Array.from(peaks);
  document.querySelector('.oa-wave-wrap').classList.add('visible');
  drawPeaks(document.getElementById('vofxWave'), vofxPeaks, '#234572');
  startWaveformAnimation();
}

/** MARK: TRACK ANALYSIS — per-track decode, peaks, and worker-transcoded preview. */

/**
 * Formats decoded in the Python worker and previewed from the returned WAV instead of
 * streaming the original. Three reasons land a format here:
 * - Chromium can't decode it at all — AIFF (no audio-element playback, no decodeAudioData peaks).
 * - Chromium CAN decode it, but its seek isn't sample-accurate — MP3. A VBR MP3 (long files
 *   especially) only carries a coarse ~100-entry seek table, so the audio element ESTIMATES
 *   the byte offset on a seek and can land ~1 s off the true time. The export reads the same
 *   MP3 through ffmpeg and seeks/trims on the sample, so preview and export would drift.
 *   Routing MP3 through the worker means preview plays a WAV decoded by the SAME ffmpeg
 *   pipeline export uses — one shared, sample-accurate timeline (and identical encoder-delay
 *   handling), so a trim/scrub lines up between preview and the saved file.
 * - It's a VIDEO container used as a track — MP4/MOV. decodeAudioData on a video container
 *   is unreliable (no peaks), and the native peaks path would fetch the ENTIRE video file
 *   (often 1-5 GB) into an ArrayBuffer just to read its audio. The worker's audio-only WAV
 *   sidesteps both, and shares the export decode's timeline like MP3 above.
 * Either way the worker reads reliably and hands back a playable WAV; export still reads the
 * original source. (m4a/aac carry accurate sample tables, so they keep streaming natively.)
 * @type {Set<string>}
 */
const BACKEND_PREVIEW_EXTS = new Set(['aif', 'aiff', 'aifc', 'mp3', 'mp4', 'mov']);

/**
 * Decode a track and draw its waveform. Formats in BACKEND_PREVIEW_EXTS are routed
 * through the worker — decodeAudioData throws on those originals — and everything
 * else is decoded natively. Bails silently if the track was removed during decode.
 * Restarts the animation loop in case it exited before peaks were ready.
 * @param {number} id - Track id in trackMap.
 * @param {string} blobUrl - The track's object URL; identity guard against removal.
 * @returns {Promise<void>}
 */
async function analyzeTrack(id, blobUrl) {
  const t0 = trackMap.get(id);
  if (t0 && BACKEND_PREVIEW_EXTS.has(t0.ext)) {
    await loadPreviewViaBackend(id, blobUrl);
    return;
  }
  try {
    const audioBuf = await decodeAudio(blobUrl);
    const t = trackMap.get(id);
    if (!t || t.blobUrl !== blobUrl) return;
    t.peaks = peaksFromBuffer(audioBuf);
    const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
    if (!card) return;
    drawPeaks(card.querySelector('.tw'), t.peaks, getTrackColor(card.classList.contains('active')));
    startWaveformAnimation();
  } catch (e) {
    console.warn(`Track ${id} waveform decode failed:`, e);
  }
}

/**
 * Prepare preview playback for formats Chromium can't decode: ask the backend to
 * transcode the source to WAV bytes, point the audio element at them (so Preview
 * plays), and derive the waveform from that same WAV (which Web Audio CAN decode).
 * The MediaElementAudioSourceNode is bound to the element, not its src, so the gain
 * routing survives the swap; setting src also fires loadedmetadata, which re-lays
 * the trim handles from the now-valid duration. Preview-only — export still reads
 * the original file through Python. The friendly track name for error messages is
 * resolved lazily: this function starts before the card is appended to the DOM
 * (createTrackCard calls analyzeTrack first), so an up-front querySelector would
 * always miss. Bails silently if the track was removed/replaced during transcode.
 * @param {number} id - Track id in trackMap.
 * @param {string} blobUrl - The track's object URL; identity guard against removal.
 * @returns {Promise<void>}
 */
async function loadPreviewViaBackend(id, blobUrl) {
  const trackName = () => document.querySelector(`.track-card[data-track-id="${id}"] .track-name`)?.textContent || 'This track';
  const t = trackMap.get(id);
  if (!t || !t.filePath || !window.ezl) {
    console.warn(`[preview] no file path for track ${id}; can't transcode for playback`);
    showError(`${trackName()} couldn't be prepared for playback — its file location is unavailable. Try removing and re-adding it.`);
    return;
  }
  try {
    const bytes = await window.ezl.getPreviewAudio(t.filePath);
    const tt = trackMap.get(id);
    if (!tt || tt.blobUrl !== blobUrl) return;

    tt.previewUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' }));
    tt.audio.src  = tt.previewUrl;

    tt.peaks = peaksFromBuffer(await decodeAudio(tt.previewUrl));
    const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
    if (card) drawPeaks(card.querySelector('.tw'), tt.peaks, getTrackColor(card.classList.contains('active')));
    startWaveformAnimation();
  } catch (e) {
    console.warn(`[preview] backend transcode failed for track ${id}:`, e?.message ?? e);
    showError(`${trackName()} couldn't be prepared for playback.`, e?.message ?? String(e));
  }
}

/** MARK: PLAYHEAD ANIMATION */

/**
 * Draw a waveform with a played/unplayed split at the playhead. The backing store is
 * only resized when dimensions actually change. The bars only span bars*slot px, with
 * up to slot-1 px of unused slack on the right; the playhead is measured in that SAME
 * bar-space (not the full canvas width) so the played/unplayed edge sits exactly on
 * the true position instead of drifting ahead of the bars — the mismatch that made an
 * earlier 1px marker (which used the full width) read a couple px ahead of the
 * illumination. The bar under the playhead is split at the exact pixel so the edge
 * tracks continuously instead of flipping whole bars, which jumps in ~60s steps on a
 * multi-hour file.
 * @param {HTMLCanvasElement} canvas - Target canvas.
 * @param {Float32Array|number[]} peaks - Normalized peak magnitudes.
 * @param {string} playedColor - Fill for bars behind the playhead.
 * @param {string} unplayedColor - Fill for bars ahead of the playhead.
 * @param {number} progress - Played fraction, 0-1.
 * @returns {void}
 */
function drawPeaksWithPlayhead(canvas, peaks, playedColor, unplayedColor, progress) {
  const dpr = window.devicePixelRatio || 1;
  const w   = canvas.offsetWidth  || 300;
  const h   = canvas.offsetHeight || 28;
  const needW = Math.round(w * dpr);
  const needH = Math.round(h * dpr);
  if (canvas.width !== needW || canvas.height !== needH) {
    canvas.width  = needW;
    canvas.height = needH;
  }
  const ctx = canvas.getContext('2d');
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, w, h);
  const bw = 2, gap = 1, slot = bw + gap;
  const bars = Math.floor(w / slot);
  const drawnW = bars * slot;
  const headX  = Math.min(Math.max(progress, 0), 1) * drawnW;
  for (let i = 0; i < bars; i++) {
    const x  = i * slot;
    const pi = Math.round(i / bars * (peaks.length - 1));
    const bh = Math.max(1, peaks[pi] * h * 0.9);
    const y  = (h - bh) / 2;
    if (x + bw <= headX) {
      ctx.fillStyle = playedColor;
      ctx.fillRect(x, y, bw, bh);
    } else if (x >= headX) {
      ctx.fillStyle = unplayedColor;
      ctx.fillRect(x, y, bw, bh);
    } else {
      const pw = headX - x;
      ctx.fillStyle = playedColor;
      ctx.fillRect(x, y, pw, bh);
      ctx.fillStyle = unplayedColor;
      ctx.fillRect(x + pw, y, bw - pw, bh);
    }
  }
}

/**
 * MARK: CLOCK INTERPOLATION — HTMLMediaElement.currentTime updates in discrete OS
 * audio-clock steps (~15-30 ms on macOS). Between ticks the value is stale, causing
 * visible stutter even in a 60 fps rAF loop. Each element is anchored to its last
 * known time + performance.now() and extrapolated forward each frame.
 */

let _videoAnchorTime = 0;
let _videoAnchorPerf = 0;
/** Per-audio-element interpolation anchors: el → { time, perf }. */
const _audioAnchors  = new WeakMap();

/**
 * Refresh the video clock anchor from the element's current time. Call after every
 * seek or discrete timeupdate tick so extrapolation stays accurate.
 * @returns {void}
 */
function _anchorVideo() {
  _videoAnchorTime = videoEl.currentTime;
  _videoAnchorPerf = performance.now();
}

/**
 * Smooth, extrapolated video playback position. Falls back to the raw currentTime
 * when paused or duration is unknown; otherwise anchor time plus elapsed wall clock
 * scaled by playbackRate, clamped to the duration.
 * @returns {number} Interpolated video time in seconds.
 */
function getVideoTime() {
  if (videoEl.paused || !videoEl.duration) return videoEl.currentTime;
  return Math.min(
    _videoAnchorTime + (performance.now() - _videoAnchorPerf) / 1000 * (videoEl.playbackRate || 1),
    videoEl.duration
  );
}

/**
 * Refresh an audio element's clock anchor from its current time.
 * @param {HTMLAudioElement} el - Element to anchor.
 * @returns {void}
 */
function _anchorAudio(el) {
  _audioAnchors.set(el, { time: el.currentTime, perf: performance.now() });
}

/**
 * Smooth, extrapolated playback position for an audio element, mirroring
 * getVideoTime. Falls back to the raw currentTime when paused, unanchored, or
 * duration is unknown.
 * @param {HTMLAudioElement} el - Element to read.
 * @returns {number} Interpolated audio time in seconds.
 */
function getAudioTime(el) {
  if (el.paused || !el.duration) return el.currentTime;
  const a = _audioAnchors.get(el);
  if (!a) return el.currentTime;
  return Math.min(a.time + (performance.now() - a.perf) / 1000 * (el.playbackRate || 1), el.duration);
}

/** MARK: RAF ANIMATION LOOP */

const _vofxWaveCanvas = document.getElementById('vofxWave');
let _rafId = null;

/**
 * Start the requestAnimationFrame loop that animates every playhead while anything
 * is playing. No-op if the loop is already running; the loop self-terminates when
 * nothing plays. Each tick does four jobs:
 * - Stops the video at its trim Out point whenever it's playing — covers both preview
 *   and bare (no active track) playback. In preview the whole preview is torn down;
 *   otherwise just the video transport pauses, matching the native 'ended' handler.
 *   With loop enabled it instead jumps seamlessly back to the In point and keeps
 *   playing (same restart path as the Restart button's wasPlaying branch), realigning
 *   the replacement OA and, in preview, the active track (which also reschedules the
 *   tail fade). Both checks are skipped while a trim handle is being dragged:
 *   shrinking Out past the live playhead must NOT stop playback mid-drag —
 *   resyncAfterTrimRelease() settles everything on release.
 * - During preview, gates the active track's audibility to its own [In, Out] window:
 *   silences (pauses) it while the shared clock sits outside, resumes in sync when it
 *   re-enters. While parked before the Music Start marker the element is PRE-SEEKED
 *   to the track's In point so crossing the marker is a bare play() with no seek —
 *   an audio-element seek at the crossing frame costs ~50ms of silence, audible as a
 *   late entry on a hard downbeat. The pre-seek fires once (later frames land inside
 *   the tolerance), so it's free per-frame. On re-entry the tail fade is deliberately
 *   NOT rescheduled: it was laid down in AudioContext (wall-clock) time and doesn't
 *   care whether the element is paused, so it still lands on silence at the video Out;
 *   re-scheduling would re-anchor currentGain mid-crossing and mis-time the tail.
 * - While the video plays, drives the scrub bar fill/head and the OA waveform playhead.
 * - Draws a live playhead on every playing track's waveform.
 * @returns {void}
 */
function startWaveformAnimation() {
  if (_rafId) return;
  function tick() {
    let anyPlaying = false;

    if (videoEl.duration && !videoEl.paused && !_trimDragging && getVideoTime() >= vOut()) {
      if (loopEnabled) {
        videoEl.currentTime = vIn();
        _anchorVideo();
        oaReplaceSeek(vIn());
        if (isPreviewing) syncActiveTrackToVideo(vIn());
      } else {
        if (isPreviewing) stopPreview();
        else { videoEl.pause(); btnPlay.innerHTML = '<span>&#x25B6;</span>'; resetVofxWaveform(); }
        // Clear the loop handle before bailing — the early return skips the assignment at
        // the bottom of tick(), so without this _rafId stays stale and the next play()
        // would see startWaveformAnimation()'s `if (_rafId) return` guard and never redraw.
        _rafId = null;
        return;
      }
    }

    if (isPreviewing && videoEl.duration && !_trimDragging) {
      const vt = getVideoTime();
      const at = trackMap.get(activeTrackId);
      if (at && at.audio && at.audio.duration) {
        const tt = trackTimeFor(at, vt);
        const within = tt >= aIn(at) && tt < aOut(at) && tt < at.audio.duration;
        if (!within) {
          if (!at.audio.paused) at.audio.pause();
          if (tt < aIn(at) && Math.abs(at.audio.currentTime - aIn(at)) > 0.02) {
            at.audio.currentTime = aIn(at);
          }
        } else if (at.audio.paused) {
          if (Math.abs(at.audio.currentTime - tt) > 0.1) at.audio.currentTime = tt;
          _anchorAudio(at.audio);
          at.audio.play().catch(() => {});
        }
      }
    }

    if (!videoEl.paused && videoEl.duration) {
      const t   = getVideoTime();
      const pct = t / videoEl.duration;
      scrubFill.style.width = (pct * 100) + '%';
      scrubHead.style.left  = (pct * 100) + '%';
      if (vofxPeaks) {
        drawPeaksWithPlayhead(_vofxWaveCanvas, vofxPeaks, '#4A8EEF', '#234572', pct);
      }
      anyPlaying = true;
    }

    trackMap.forEach(({ audio, peaks }, id) => {
      if (audio.paused || !peaks || !audio.duration) return;
      const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
      if (!card) return;
      const tw     = card.querySelector('.tw');
      const active = card.classList.contains('active');
      drawPeaksWithPlayhead(
        tw, peaks,
        active ? '#6BAEF0' : '#3A5878',
        active ? '#3A6EBF' : '#283545',
        getAudioTime(audio) / audio.duration
      );
      anyPlaying = true;
    });

    _rafId = anyPlaying ? requestAnimationFrame(tick) : null;
  }
  _rafId = requestAnimationFrame(tick);
}

/**
 * Repaint the OA waveform in its resting (no playhead) state — real peaks if known,
 * placeholder otherwise.
 * @returns {void}
 */
function resetVofxWaveform() {
  const canvas = document.getElementById('vofxWave');
  if (vofxPeaks) drawPeaks(canvas, vofxPeaks, '#234572');
  else drawWavePlaceholder(canvas, 101, '#1A2E48');
}

/**
 * Repaint a track's waveform in its resting (no playhead) state — real peaks if
 * known, placeholder otherwise.
 * @param {number} id - Track id in trackMap.
 * @returns {void}
 */
function resetTrackWaveform(id) {
  const t = trackMap.get(id);
  if (!t) return;
  const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
  if (!card) return;
  const tw    = card.querySelector('.tw');
  const color = getTrackColor(card.classList.contains('active'));
  if (t.peaks) drawPeaks(tw, t.peaks, color);
  else drawWavePlaceholder(tw, t.seed, color);
}

/**
 * Static (non-rAF) redraw of a track's waveform at a given played fraction, using the
 * same colors as the live animation loop. Needed because the rAF loop skips paused
 * tracks, so a track parked at its end after a seek would otherwise keep stale
 * shading. No-op on placeholder waveforms, which have no progress concept.
 * @param {number} id - Track id in trackMap.
 * @param {number} progress - Played fraction 0-1; 1 shades the whole playable region,
 *   signalling the track is at/past its end.
 * @returns {void}
 */
function drawTrackShade(id, progress) {
  const t = trackMap.get(id);
  if (!t || !t.peaks) return;
  const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
  if (!card) return;
  const active = card.classList.contains('active');
  drawPeaksWithPlayhead(
    card.querySelector('.tw'), t.peaks,
    active ? '#6BAEF0' : '#3A5878',
    active ? '#3A6EBF' : '#283545',
    progress
  );
}

/**
 * Draw a track's waveform shading for a given video position WITHOUT touching
 * playback, so a paused seek / track switch shows the correct state immediately (the
 * rAF loop only runs while playing). At/past the track's end (duration or trim-out)
 * the waveform is fully shaded, for any track. Within range the ACTIVE track shows a
 * live playhead at the current position; inactive tracks stay at base so they don't
 * freeze a stale playhead while another track is previewing. For any video time
 * before the Music Start marker the shade holds at the track's In point — nothing has
 * played yet, and the shade must not creep below the trimmed-in head.
 * @param {number} id - Track id in trackMap.
 * @param {number} videoTime - Video-timeline position in seconds.
 * @returns {void}
 */
function refreshTrackShade(id, videoTime) {
  const t = trackMap.get(id);
  if (!t) return;
  const dur = trackDuration(t);
  if (!videoLoaded || dur <= 0 || !t.peaks) { resetTrackWaveform(id); return; }
  const tt = trackTimeFor(t, videoTime);
  if (tt >= aOut(t)) { drawTrackShade(id, 1); return; }
  const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
  if (card?.classList.contains('active')) {
    drawTrackShade(id, Math.max(aIn(t), Math.min(tt, dur)) / dur);
  }
  else resetTrackWaveform(id);
}

/**
 * Update the paused video playhead UI — scrub bar fill/head plus OA waveform
 * shading — to a given time. The rAF loop does this each frame while playing; when
 * paused it must be done explicitly so a seek gives immediate feedback (the video
 * frame already jumped via currentTime). Mirrors the video-playing draw block in the
 * rAF loop.
 * @param {number} time - Video time in seconds.
 * @returns {void}
 */
function drawStaticVideoPlayhead(time) {
  if (!videoEl.duration) return;
  const pct = time / videoEl.duration;
  scrubFill.style.width = (pct * 100) + '%';
  scrubHead.style.left  = (pct * 100) + '%';
  if (vofxPeaks) drawPeaksWithPlayhead(_vofxWaveCanvas, vofxPeaks, '#4A8EEF', '#234572', pct);
  else resetVofxWaveform();
}

/**
 * MARK: TRIM UI — trim handles are plain DOM overlays positioned as a % of the
 * bar/canvas width, so they sit on top of the live waveform without the 60fps
 * redraw loop ever touching them.
 */

/**
 * Clamp a number into [0, 1].
 * @param {number} x - Value to clamp.
 * @returns {number} Clamped value.
 */
const clamp01 = x => Math.max(0, Math.min(1, x));

/**
 * Position a trim surface's In/Out handles, its two dim panels, and (when present)
 * the draggable band spanning In to Out, from fractional positions.
 * @param {{in: HTMLElement, out: HTMLElement, dimL: HTMLElement, dimR: HTMLElement,
 *   region: (HTMLElement|null|undefined)}} els - Elements of one trim surface.
 * @param {number} startFrac - In position as a fraction of the surface width (0-1).
 * @param {number} endFrac - Out position as a fraction of the surface width (0-1).
 * @returns {void}
 */
function layoutTrim(els, startFrac, endFrac) {
  els.in.style.left    = (startFrac * 100) + '%';
  els.out.style.left   = (endFrac   * 100) + '%';
  els.dimL.style.width = (startFrac * 100) + '%';
  els.dimR.style.width = ((1 - endFrac) * 100) + '%';
  if (els.region) {
    els.region.style.left  = (startFrac * 100) + '%';
    els.region.style.width = ((endFrac - startFrac) * 100) + '%';
  }
}

/**
 * Trim-surface elements for the scrub bar.
 * @returns {object} Element map for layoutTrim.
 */
function videoTrimEls() {
  return { in: scrubTrimIn, out: scrubTrimOut, dimL: scrubDimL, dimR: scrubDimR, region: scrubRegion };
}

/**
 * Trim-surface elements for the OA waveform, which mirrors the SAME video trim —
 * its handles are locked to the scrub handles.
 * @returns {object} Element map for layoutTrim.
 */
function oaTrimEls() {
  return { in: oaTrimIn, out: oaTrimOut, dimL: oaDimL, dimR: oaDimR, region: oaRegion };
}

/**
 * Lay out the video trim handles on both surfaces (scrub bar + the OA waveform,
 * locked to the same trim) from the current trim state. Also relays out the Music
 * Start marker: it lives inside the same window, and doing it here rather than at
 * each call site means it follows along at every existing relayout point
 * (loadedmetadata reset, project restore, both video handle drags) for free.
 * @returns {void}
 */
function updateVideoTrimUI() {
  const dur = videoEl.duration;
  if (!dur || !isFinite(dur)) return;
  const startFrac = (videoTrimStart || 0) / dur;
  const endFrac   = (videoTrimEnd ?? dur) / dur;
  layoutTrim(videoTrimEls(), startFrac, endFrac);
  layoutTrim(oaTrimEls(),    startFrac, endFrac);
  updateMusicStartUI();
}

/**
 * Position both Music Start markers from the same state — the OA marker is locked to
 * the scrub marker exactly like the trim handles are.
 * @returns {void}
 */
function updateMusicStartUI() {
  const dur = videoEl.duration;
  if (!dur || !isFinite(dur)) return;
  const pct = (clamp01(mStart() / dur) * 100) + '%';
  scrubMusicMarker.style.left = pct;
  oaMusicMarker.style.left    = pct;
}

/**
 * Trim-surface elements for one track card's waveform.
 * @param {HTMLElement} card - The track card element.
 * @returns {object} Element map for layoutTrim.
 */
function trackTrimEls(card) {
  return {
    in:     card.querySelector('.tw-trim-in'),
    out:    card.querySelector('.tw-trim-out'),
    dimL:   card.querySelector('.tw-dim-l'),
    dimR:   card.querySelector('.tw-dim-r'),
    region: card.querySelector('.tw-region'),
  };
}

/**
 * Track length in seconds for the trim system. Returns 0 (not NaN) until the audio
 * element reports a finite duration, so callers bail cleanly until it's known — the
 * loadedmetadata listener re-runs the trim layout once it lands (for AIFF, that's
 * when the transcoded WAV loads, since Chromium can't decode the source AIFF
 * directly).
 * @param {object|undefined} t - Track entry from trackMap.
 * @returns {number} Duration in seconds, or 0 while unknown.
 */
function trackDuration(t) {
  return (t?.audio?.duration && isFinite(t.audio.duration)) ? t.audio.duration : 0;
}

/**
 * Lay out one track's trim handles from its stored trim state. No-op until the
 * track's duration is known.
 * @param {number} id - Track id in trackMap.
 * @returns {void}
 */
function updateTrackTrimUI(id) {
  const t = trackMap.get(id);
  const dur = trackDuration(t);
  if (!dur) return;
  const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
  if (!card) return;
  layoutTrim(trackTrimEls(card), (t.trimStart || 0) / dur, (t.trimEnd ?? dur) / dur);
}

/**
 * The furthest a track's Out point may reach given the active video section,
 * measured from the track's own In point: audioIn + (vOut − musicStart). Infinity
 * when no video is loaded. The track only occupies [musicStart, vOut] of the
 * picture, so pushing the Music Start marker right SHRINKS this cap — which is what
 * pulls every track's effective Out handle inward live (via syncTrackEndToVideo) and
 * what caps each track's own Out drag (via getMaxEnd).
 * @param {object|undefined} t - Track entry from trackMap.
 * @returns {number} Cap in track-timeline seconds, or Infinity.
 */
function videoCapForTrack(t) {
  if (!t || !(videoLoaded && videoEl.duration && isFinite(videoEl.duration))) return Infinity;
  return Timeline.videoCapFor({ audioIn: t.trimStart || 0, musicStart: mStart(), videoOut: vOut() });
}

/**
 * Recompute a track's *effective* Out handle (trimEnd) from two inputs:
 * trimEndTarget — the user's saved custom end (null = "use the whole track") — and
 * videoCapForTrack — the ceiling imposed by the active video section. The effective
 * end is the smaller of the two, so the handle only moves inward when the target
 * would run past the video end, and relaxes back toward the saved target as the
 * section grows. Stored as null when it reaches the track's full duration. Always
 * relays out the handles, so it doubles as the "lay out once duration is known" call.
 * @param {number} id - Track id in trackMap.
 * @returns {void}
 */
function syncTrackEndToVideo(id) {
  const t = trackMap.get(id);
  const dur = trackDuration(t);
  if (!dur) return;
  const target    = t.trimEndTarget ?? dur;
  const effective = Math.min(target, videoCapForTrack(t));
  t.trimEnd = (effective >= dur) ? null : effective;
  updateTrackTrimUI(id);
}

/**
 * Re-run the effective-Out auto-set for every loaded track — called on video load
 * and on each video trim-handle change so the audio Out handles follow the active
 * section live.
 * @returns {void}
 */
function syncAudioEndsToVideo() {
  trackMap.forEach((_t, id) => syncTrackEndToVideo(id));
}

/** MARK: Trim-handle frame preview */

let _fpSeeking = false, _fpPending = null;

/**
 * Paint the frame at a given time into the popover canvas, sourced from the hidden
 * #scrubVideo (a second decoder on the same file) so the MAIN player is never
 * disturbed. Seeks are COALESCED: while one is in flight only the newest target is
 * remembered and fired when the current seek resolves, so a fast drag catches up to
 * the cursor instead of grinding through every intermediate position.
 * @param {number} sec - Video time in seconds whose frame to paint.
 * @returns {void}
 */
function paintPreviewFrame(sec) {
  if (!scrubVideo.src) return;
  if (_fpSeeking) { _fpPending = sec; return; }
  _fpSeeking = true;
  const draw = () => {
    const ctx = framePreviewCanvas.getContext('2d');
    ctx.drawImage(scrubVideo, 0, 0, framePreviewCanvas.width, framePreviewCanvas.height);
    _fpSeeking = false;
    if (_fpPending != null) { const next = _fpPending; _fpPending = null; paintPreviewFrame(next); }
  };
  scrubVideo.addEventListener('seeked', draw, { once: true });
  scrubVideo.currentTime = Math.max(0, sec);
}

/**
 * True when the main player is running (full preview OR bare video playback).
 * @returns {boolean} Whether anything is playing.
 */
const isPlaying = () => isPreviewing || !videoEl.paused;

/**
 * Show the frame at a given time for a video/OA handle or marker drag. Paused: move
 * the MAIN player to it (full-size, familiar), parking the playhead on the cut point
 * so the user sees it. Playing: the pinned thumbnail only, so playback isn't
 * interrupted. The thumbnail sits in the filename/readout slot, so both step aside
 * while it's up.
 * @param {number} sec - Video time in seconds.
 * @param {string} [caption=''] - Optional text rendered under the thumbnail (the
 *   marker passes its time there, since the thumbnail occupies the readout's corner
 *   while it's up).
 * @returns {void}
 */
function previewFrameAt(sec, caption = '') {
  if (isPlaying()) {
    framePreviewCap.textContent = caption;
    framePreview.hidden = false;
    videoFilename.style.visibility = 'hidden';
    videoMarkerReadout.classList.remove('visible');
    paintPreviewFrame(sec);
  } else {
    videoEl.currentTime = sec;
    _anchorVideo();
  }
}

/**
 * Hide the frame-preview thumbnail and give the filename its corner back.
 * @returns {void}
 */
function hideFramePreview() {
  framePreview.hidden = true;
  framePreviewCap.textContent = '';
  videoFilename.style.visibility = '';
}

/**
 * Generic drag wiring for an In/Out trim-handle pair (plus an optional draggable
 * band). opt supplies the bar element to measure against, the two handles, a tooltip
 * element, the duration, and getters/setters for the trim state — so the same code
 * drives both the video and per-track trims. Handles enforce a minimum In/Out gap,
 * cap Out at getMaxEnd (the video-section cap for tracks; the full duration for the
 * video bar, where getMaxEnd is omitted), and snap Out to "full extent" (null) when
 * dragged to the end of the allowed range. Double-click resets a handle to its
 * full-extent default (In to 0, Out to null). During a drag _trimDragging is set;
 * it's cleared on a deferred timeout so the click event that follows mouseup is
 * still suppressed. On release resyncAfterTrimRelease() re-syncs the previewing
 * track to the new trim offset and jumps the playhead back into the window if it
 * now sits outside it (the video keeps playing through the drag).
 *
 * setStart/setEnd receive a second argument naming the gesture ('in' | 'out' |
 * 'slide') so a consumer can react differently to "one edge moved" vs "the whole
 * window slid" — the video call sites use it to decide whether the Music Start
 * marker is pushed or rides along. The per-track trims declare one parameter and
 * simply drop it; they have nothing that rides.
 * @param {object} opt - Wiring options.
 * @param {HTMLElement} opt.rectEl - Bar element measured for pixel-to-seconds math.
 * @param {HTMLElement} opt.handleIn - In handle.
 * @param {HTMLElement} opt.handleOut - Out handle.
 * @param {HTMLElement} [opt.handleRegion] - Draggable band between the handles.
 * @param {HTMLElement} opt.tooltipEl - Tooltip shown while dragging.
 * @param {function(): number} opt.getDuration - Duration of the trimmed media in seconds.
 * @param {function(): number} opt.getStart - Current In point in seconds.
 * @param {function(): (number|null)} opt.getEnd - Current Out point (null = full extent).
 * @param {function(): number} [opt.getMaxEnd] - Ceiling for the Out handle.
 * @param {function(number, string): void} opt.setStart - Write the In point.
 * @param {function((number|null), string): void} opt.setEnd - Write the Out point.
 * @param {function(): void} opt.relayout - Re-lay-out the handles after a state change.
 * @param {function(number): void} [opt.onPreview] - Video/OA handles only: show the
 *   frame at this point (pinned thumbnail while playing, main-player seek while
 *   paused). Per-track audio handles omit it.
 * @param {function(number): void} [opt.onRegionClick] - Click-to-seek handler for
 *   a band click that never became a drag.
 * @returns {void}
 */
function attachTrimDrag(opt) {
  function bind(handle, isIn) {
    handle.addEventListener('mousedown', e => {
      e.stopPropagation();  // don't let the bar's click-to-seek fire
      e.preventDefault();   // don't start a text selection
      const dur = opt.getDuration();
      if (!dur || !isFinite(dur)) return;

      _trimDragging = true;
      handle.classList.add('dragging');
      opt.tooltipEl.classList.add('visible');
      const rect = opt.rectEl.getBoundingClientRect();
      const gap  = Math.max(0.05, 0.01 * dur); // keep In and Out at least this far apart

      function move(ev) {
        let sec = clamp01((ev.clientX - rect.left) / rect.width) * dur;
        if (isIn) {
          const end = opt.getEnd() ?? dur;
          sec = Math.max(0, Math.min(sec, end - gap));
          opt.setStart(sec, 'in');
        } else {
          const start  = opt.getStart() || 0;
          const maxEnd = opt.getMaxEnd ? opt.getMaxEnd() : dur;
          sec = Math.max(sec, start + gap);
          sec = Math.min(sec, maxEnd);
          opt.setEnd(sec >= maxEnd - gap ? null : sec, 'out');
        }
        opt.relayout();
        opt.tooltipEl.style.left   = (clamp01(sec / dur) * 100) + '%';
        opt.tooltipEl.textContent  = fmt(sec);
        opt.onPreview && opt.onPreview(sec);
      }
      function up() {
        document.removeEventListener('mousemove', move);
        document.removeEventListener('mouseup', up);
        handle.classList.remove('dragging');
        opt.tooltipEl.classList.remove('visible');
        if (opt.onPreview) hideFramePreview();
        resyncAfterTrimRelease();
        // Defer clearing so the click event that follows mouseup is still suppressed
        setTimeout(() => { _trimDragging = false; }, 0);
      }
      document.addEventListener('mousemove', move);
      document.addEventListener('mouseup', up);
    });

    handle.addEventListener('dblclick', e => {
      e.stopPropagation();
      e.preventDefault();
      if (isIn) opt.setStart(0); else opt.setEnd(null);
      opt.relayout();
    });
  }
  /**
   * Wire the draggable band: dragging slides the whole trim WINDOW by its midpoint —
   * both edges move together, width fixed. A press that never crosses the 3px
   * movement threshold is treated as a click and forwarded to opt.onRegionClick, so
   * click-to-seek inside the window still works. The position ceiling for the Out
   * edge is the full duration: the per-track video cap limits the window's *width*,
   * not where it sits — and width is preserved throughout the drag, so a window that
   * fits the video section stays valid wherever it slides. Out snaps to "full extent"
   * (null) only at the true end. The tooltip is centred over the band and shows the
   * window as "M:SS – M:SS"; the leading (In) edge is previewed as the window slides
   * (the thumbnail is pinned to the picture's corner, so there's nothing to
   * position). On release resyncAfterTrimRelease() re-aligns the previewing track
   * and jumps the playhead back inside if the slide carried the window past it.
   * @param {HTMLElement} region - The band element.
   * @returns {void}
   */
  function bindRegion(region) {
    let didDrag = false;
    region.addEventListener('mousedown', e => {
      e.preventDefault();                           // don't start a text selection
      const dur = opt.getDuration();
      if (!dur || !isFinite(dur)) return;
      const rect   = opt.rectEl.getBoundingClientRect();
      const gap    = Math.max(0.05, 0.01 * dur);
      const startX = e.clientX;
      const base0  = opt.getStart() || 0;
      const base1  = opt.getEnd() ?? dur;
      const width  = base1 - base0;
      const effMax = dur;
      let dragging = false;
      didDrag = false;

      function move(ev) {
        const dx = ev.clientX - startX;
        if (!dragging && Math.abs(dx) < 3) return;  // below threshold → still a click, not a drag
        if (!dragging) {
          dragging = true; didDrag = true; _trimDragging = true;
          region.classList.add('dragging');
          opt.tooltipEl.classList.add('visible');
        }
        const deltaSec = (dx / rect.width) * dur;
        const newStart = Math.max(0, Math.min(base0 + deltaSec, effMax - width));
        const newEnd   = newStart + width;
        opt.setStart(newStart, 'slide');
        opt.setEnd(newEnd >= dur - gap ? null : newEnd, 'slide');
        opt.relayout();
        opt.tooltipEl.style.left  = (clamp01(((newStart + newEnd) / 2) / dur) * 100) + '%';
        opt.tooltipEl.textContent = fmt(newStart) + ' – ' + fmt(newEnd);
        opt.onPreview && opt.onPreview(newStart);
      }
      function up() {
        document.removeEventListener('mousemove', move);
        document.removeEventListener('mouseup', up);
        region.classList.remove('dragging');
        opt.tooltipEl.classList.remove('visible');
        if (opt.onPreview) hideFramePreview();
        resyncAfterTrimRelease();
        setTimeout(() => { _trimDragging = false; }, 0);
      }
      document.addEventListener('mousemove', move);
      document.addEventListener('mouseup', up);
    });

    // A click that never became a drag = a seek. stopPropagation keeps the scrub bar's own
    // click handler from double-firing; OA/track seek via onRegionClick (their click handlers
    // live on the canvas, which the region doesn't bubble up to).
    region.addEventListener('click', e => {
      e.stopPropagation();
      if (didDrag) return;
      opt.onRegionClick && opt.onRegionClick(e.clientX);
    });
  }

  bind(opt.handleIn,  true);
  bind(opt.handleOut, false);
  if (opt.handleRegion) bindRegion(opt.handleRegion);
}

/** MARK: Preview trim bounds */

/**
 * Video trim In point in seconds (0 when untrimmed).
 * @returns {number}
 */
const vIn  = () => videoTrimStart || 0;

/**
 * Video trim Out point in seconds; defaults to the full duration when untrimmed.
 * @returns {number}
 */
const vOut = () => (videoTrimEnd ?? videoEl.duration);

/**
 * A track's audio In point in seconds (0 when untrimmed).
 * @param {object|undefined} t - Track entry from trackMap.
 * @returns {number}
 */
const aIn  = t => (t?.trimStart || 0);

/**
 * A track's effective audio Out point in seconds; falls back to the track duration
 * (or Infinity while unknown) when untrimmed.
 * @param {object|undefined} t - Track entry from trackMap.
 * @returns {number}
 */
const aOut = t => (t?.trimEnd ?? (trackDuration(t) || Infinity));

/**
 * Map the video clock to the active track's own timeline so the track's In point
 * plays at the MUSIC START marker (which defaults to the video In, matching export).
 * Identity (trackTime === videoTime) when nothing is trimmed and no marker is placed.
 *
 * A video time BEFORE the marker maps BELOW the track's In point. That's not an edge
 * case to guard against — it's the signal every playback path uses to mean "the
 * music hasn't come in yet" (see the pre-marker branches in syncActiveTrackToVideo /
 * setActiveTrack and the rAF audibility gate, all of which test tt < aIn(t)).
 * @param {object|undefined} t - Track entry from trackMap.
 * @param {number} videoTime - Video-timeline position in seconds.
 * @returns {number} Track-timeline position in seconds.
 */
const trackTimeFor = (t, videoTime) =>
  Timeline.trackTimeFor({ audioIn: aIn(t), musicStart: mStart(), videoTime });

/**
 * The inverse of trackTimeFor — a moment in the SONG mapped to the video clock
 * position that plays it. Needed by track-waveform click-to-seek, which measures in
 * track time while doSeek() speaks video time.
 * @param {object|undefined} t - Track entry from trackMap.
 * @param {number} trackTime - Track-timeline position in seconds.
 * @returns {number} Video-timeline position in seconds.
 */
const videoTimeForTrack = (t, trackTime) =>
  Timeline.videoTimeForTrack({ audioIn: aIn(t), musicStart: mStart(), trackTime });

/** MARK: MUSIC START MARKER */

/**
 * Minimum separation the trim engine keeps between In and Out — reused so the
 * marker can never land on top of the Out handle's grab zone. Same expression as
 * attachTrimDrag's local gap.
 * @returns {number} Gap in seconds.
 */
const trimGap = () => Math.max(0.05, 0.01 * (videoEl.duration || 0));

/**
 * The effective Music Start on the video timeline; reads as the video In point when
 * the marker was never placed.
 *
 * Clamped on READ as well as on write. The trim-handle drags push the marker
 * explicitly through their setStart/setEnd, so the two never disagree during a
 * gesture — this guard is for the paths nobody pushed through: project restore, a
 * proxy swap, a window that shrank around it. The !isFinite check matters: with no
 * video loaded vOut() is videoEl.duration = NaN, and an unguarded Math.min would
 * poison trackTimeFor with NaN for every track.
 * @returns {number} Effective marker position in seconds.
 */
const mStart = () => {
  const inPt = vIn();
  if (musicStartTime == null || !isFinite(vOut())) return inPt;
  return Timeline.clampMusicStart(musicStartTime, inPt, vOut(), trimGap());
};

/**
 * Where to park a track's audio element for a given video time: its offset position,
 * clamped into the track's own [In, duration]. Before the Music Start marker that's
 * the In point — the track waits there, pre-seeked and silent, until the marker
 * arrives.
 * @param {object|undefined} t - Track entry from trackMap.
 * @param {number} videoTime - Video-timeline position in seconds.
 * @returns {number} Track-timeline park position in seconds.
 */
function parkTrackAt(t, videoTime) {
  const dur = trackDuration(t) || Infinity;
  return Math.max(aIn(t), Math.min(trackTimeFor(t, videoTime), dur));
}

/**
 * SINGLE writer for the Music Start marker (null = "not placed"). Always clamps,
 * relays out both surfaces, and re-fits every track's Out handle — moving the marker
 * changes videoCapForTrack for every loaded track, so syncAudioEndsToVideo lives
 * here rather than at each call site.
 * @param {number|null} sec - New marker position in seconds, or null to unset.
 * @returns {void}
 */
function setMusicStart(sec) {
  musicStartTime = sec == null ? null : Timeline.clampMusicStart(sec, vIn(), vOut(), trimGap());
  updateMusicStartUI();
  syncAudioEndsToVideo();
}

/**
 * Show the Music Start readout over the picture. The filename occupies the same
 * slot, so it steps aside while the readout is up rather than stacking underneath
 * it — via visibility, not display: loadVideoDescriptor owns display and must not be
 * fought over.
 * @param {number} sec - Marker time to display.
 * @returns {void}
 */
function showMarkerReadout(sec) {
  videoMarkerReadout.textContent = 'Music Start · ' + fmt(sec);
  videoMarkerReadout.classList.add('visible');
  videoFilename.style.visibility = 'hidden';
}

/**
 * Hide the Music Start readout and restore the filename's visibility.
 * @returns {void}
 */
function hideMarkerReadout() {
  videoMarkerReadout.classList.remove('visible');
  videoFilename.style.visibility = '';
}

/**
 * Drag wiring for the Music Start marker. Deliberately NOT folded into
 * attachTrimDrag: that engine also drives every per-track audio trim, which must
 * never touch the marker, and the marker has no partner handle, no width to
 * preserve, and no "snap to full extent" null state. It reuses the same
 * _trimDragging / .dragging / resyncAfterTrimRelease conventions, so the two feel
 * identical to use — but the time readout goes over the picture, not into a handle
 * tooltip, so it sits beside the frame it's choosing.
 *
 * Setting _trimDragging during the gesture is intentional and does three things: it
 * suppresses the click that follows mouseup, and it pauses the rAF loop's
 * stop-at-Out and audibility blocks — so the active track keeps playing at its
 * pre-drag position through the gesture instead of stuttering on every mousemove,
 * and resyncAfterTrimRelease() settles it on release (it may need to fall silent, or
 * to come in immediately). Same deal the trim handles already get.
 *
 * Each mousemove routes through setMusicStart, which clamps into [vIn, vOut − gap],
 * relays out BOTH markers, and re-fits every track's Out handle — so the track cards
 * visibly follow the drag. The drag also shows the same frame affordance as the
 * video In/Out handles (pinned thumbnail while playing, main-player seek while
 * paused): "where in the picture does the music come in" is exactly a frame
 * decision, so this matters more here than on the trim handles. Paused, the readout
 * lands directly on the frame it names; playing, the thumbnail takes that corner and
 * carries the same text as its caption, so nothing is lost either way. Double-click
 * resets the marker to "not placed" — i.e. back to the video In point.
 * @param {object} opt - Wiring options.
 * @param {HTMLElement} opt.rectEl - Bar element measured for pixel-to-seconds math
 *   (cached once at mousedown, like attachTrimDrag).
 * @param {HTMLElement} opt.marker - The marker element to wire.
 * @returns {void}
 */
function attachMarkerDrag(opt) {
  const { rectEl, marker } = opt;

  marker.addEventListener('mousedown', e => {
    e.stopPropagation();  // don't let the bar's click-to-seek or the region's drag fire
    e.preventDefault();   // don't start a text selection
    const dur = videoEl.duration;
    if (!videoLoaded || !dur || !isFinite(dur)) return;

    _trimDragging = true;
    marker.classList.add('dragging');
    showMarkerReadout(mStart());
    const rect = rectEl.getBoundingClientRect();

    function move(ev) {
      setMusicStart(clamp01((ev.clientX - rect.left) / rect.width) * dur);
      const sec = mStart();
      showMarkerReadout(sec);
      previewFrameAt(sec, 'Music Start · ' + fmt(sec));
    }
    function up() {
      document.removeEventListener('mousemove', move);
      document.removeEventListener('mouseup', up);
      marker.classList.remove('dragging');
      hideMarkerReadout();
      hideFramePreview();
      resyncAfterTrimRelease();
      // Defer clearing so the click event that follows mouseup is still suppressed
      setTimeout(() => { _trimDragging = false; }, 0);
    }
    document.addEventListener('mousemove', move);
    document.addEventListener('mouseup', up);
  });

  marker.addEventListener('dblclick', e => {
    e.stopPropagation();
    e.preventDefault();
    setMusicStart(null);
    resyncAfterTrimRelease();
  });
}

/** MARK: TIME FORMAT */

/**
 * Format seconds as M:SS for tooltips and readouts. Non-finite input reads "0:00".
 * @param {number} s - Time in seconds.
 * @returns {string} Formatted time.
 */
function fmt(s) {
  if (!isFinite(s)) return '0:00';
  const m = Math.floor(s / 60);
  return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
}

/** MARK: TRACK FADE HELPERS */

/** Length of the music tail fade in seconds — shared by preview and export. */
const TRACK_FADE_SEC = TrackFade.TRACK_FADE_SEC;

/**
 * Time constant for smoothing a live gain change (slider drags) — short enough to
 * feel instant, long enough not to click.
 * @type {number}
 */
const GAIN_SMOOTH_SEC = 0.005;

/**
 * Schedule a fade-out so the track lands on silence at the video's end. Only fades
 * when the track's content runs past the trimmed video window — its Out gets capped
 * to the window end (a hard cut that needs de-clicking). videoCapForTrack folds in
 * both the track's In point and the window length, so a trimmed-in video (vIn > 0)
 * is handled too. The event schedule lives in TrackFade.computeFadeSchedule; this
 * just replays it onto the gain node.
 * @param {object|undefined} t - Track entry from trackMap.
 * @param {number} videoCurrentTime - Current video time in seconds.
 * @param {number} videoDuration - End of the video window in seconds (vOut).
 * @param {object} [opts] - Overrides.
 * @param {number} [opts.gain] - Level the fade holds at, overriding gain.value — a
 *   slider's TARGET value, which gain.value doesn't yet read as mid-glide.
 * @param {number} [opts.rampSec=0] - Glide to the hold level over this many seconds
 *   instead of jumping.
 * @returns {boolean} True if a fade was actually scheduled — setTrackGain uses this
 *   to know whether the schedule already wrote the new level, or whether it still
 *   has to write it itself.
 */
function scheduleTrackFade(t, videoCurrentTime, videoDuration, { gain, rampSec = 0 } = {}) {
  if (!t?.gainNode || !t.audio.duration) return false;
  const intendedOut = t.trimEndTarget ?? t.audio.duration;
  if (intendedOut <= videoCapForTrack(t)) return false; // track ends within the window — no fade

  const audioCtx = getAudioCtx();
  const events = TrackFade.computeFadeSchedule({
    currentGain:      gain ?? t.gainNode.gain.value,
    videoCurrentTime,
    videoDuration,
    now:              audioCtx.currentTime,
    rampSec,
  });

  for (const e of events) {
    if (e.method === 'cancelScheduledValues')          t.gainNode.gain.cancelScheduledValues(e.time);
    else if (e.method === 'setValueAtTime')            t.gainNode.gain.setValueAtTime(e.value, e.time);
    else if (e.method === 'setTargetAtTime')           t.gainNode.gain.setTargetAtTime(e.value, e.time, e.timeConstant);
    else if (e.method === 'linearRampToValueAtTime')   t.gainNode.gain.linearRampToValueAtTime(e.value, e.time);
  }
  return true;
}

/**
 * Write a track's preview gain, keeping any scheduled tail fade in step with it.
 *
 * The fade is laid down AHEAD of time as absolute Web Audio events (at preview start
 * and on every seek): hold at the gain that was current then, until 0.5 s before the
 * video window ends, then ramp to silence. A later Auto-Mix or slider move that
 * writes only "now" leaves that stale hold value sitting in the future, so the level
 * jumped back the instant the fade began — issue #342. Rebuilding the schedule from
 * the new value keeps the fade continuous.
 *
 * Only the active track during preview can have live fade events on its timeline;
 * every other write is a plain set. Past the window end there's no fade left to
 * rebuild — and rescheduling there would put the ramp in the past; cancelTrackFade
 * restores the slider level on stop anyway. Rescheduling writes the new level itself
 * (its anchor event), so the bare write only happens when there was no fade to
 * rebuild.
 * @param {object|undefined} t - Track entry from trackMap.
 * @param {number} id - Track id in trackMap.
 * @param {number} linear - New linear gain value.
 * @param {object} [opts] - Options.
 * @param {boolean} [opts.smooth=false] - Glide to the value instead of jumping
 *   (slider drags).
 * @returns {void}
 */
function setTrackGain(t, id, linear, { smooth = false } = {}) {
  if (!t?.gainNode) return;
  const live = isPreviewing && id === activeTrackId && videoEl.currentTime < vOut();
  const rampSec = smooth ? GAIN_SMOOTH_SEC : 0;
  if (live && scheduleTrackFade(t, videoEl.currentTime, vOut(), { gain: linear, rampSec })) return;
  if (smooth) t.gainNode.gain.setTargetAtTime(linear, getAudioCtx().currentTime, GAIN_SMOOTH_SEC);
  else        t.gainNode.gain.value = linear;
}

/**
 * Seconds of linear tail fade-out to apply to a track on export. Matches the preview
 * fade in scheduleTrackFade: only when the track outlasts the trimmed video window —
 * its intended Out (user target, or full duration) runs past the window cap and gets
 * hard-cut at the video Out. Otherwise 0 — a track that ends inside the window fades
 * naturally.
 * @param {object} t - Track entry from trackMap.
 * @returns {number} Fade length in seconds (TRACK_FADE_SEC or 0).
 */
function exportFadeOutFor(t) {
  const dur = trackDuration(t);
  if (!dur) return 0;
  const intendedOut = t.trimEndTarget ?? dur;
  return intendedOut > videoCapForTrack(t) ? TRACK_FADE_SEC : 0;
}

/** MARK: EXPORT METADATA */

/**
 * Output containers that can be tagged. MP4 and MOV both tag through mutagen's MP4
 * writer — they share the same ISO/iTunes atom layout (MOV being mutagen-readable is
 * under empirical test). Any other container is skipped so spurious failures aren't
 * logged for formats mutagen can't tag.
 * @type {RegExp}
 */
const TAGGABLE_OUTPUT_EXT = /\.(mp4|mov)$/i;

/**
 * Translate the persisted Settings into the worker's MP4 tag vocabulary, dropping
 * any empty/unset field so only tags the user actually filled in are written. The
 * worker keys off these names via the T table in mp4_metadata.py; for artwork it
 * reads the image bytes from the given path.
 * @param {object} cfg - Settings object from window.ezl.getSettings().
 * @returns {object} Tag map for the update_metadata worker message.
 */
function buildExportMetadata(cfg) {
  const m = cfg?.metadata ?? {};
  const out = {};
  if (m.artist)      out.artist   = m.artist;
  if (m.album)       out.album    = m.album;
  if (m.composer)    out.composer = m.composer;
  if (m.grouping)    out.grouping = m.grouping;
  if (m.comments)    out.comments = m.comments;
  if (m.artworkPath) out.artwork  = m.artworkPath;
  return out;
}

/**
 * After a successful export, stamp metadata onto each output file via a SEPARATE
 * worker message (update_metadata): ffmpeg writes the file, then mutagen patches its
 * tags. Always sends — even with no user fields — because the worker also adds the
 * branded "Created By" tag (ADD_CREATED_BY_TAG). Containers mutagen can't tag are
 * skipped. Failures are non-fatal: the layback already saved.
 * @param {string[]} outputPaths - Paths of the exported files.
 * @param {object} cfg - Settings object feeding buildExportMetadata.
 * @returns {Promise<void>}
 */
async function applyExportMetadata(outputPaths, cfg) {
  const metadata = buildExportMetadata(cfg);
  for (const path of outputPaths) {
    if (!TAGGABLE_OUTPUT_EXT.test(path)) continue;
    try {
      const res = await window.ezl.updateMetadata({ path, metadata });
      if (res.status !== 'ok') console.error('[metadata] failed for', path, res.error);
    } catch (err) {
      console.error('[metadata] error for', path, err);
    }
  }
}

/**
 * Cancel any scheduled fade and snap the gain back to the track's slider value
 * (immediate restore — no ramp needed). Called when pausing, stopping, or switching
 * tracks so a mid-fade state is never left hanging on a gain node.
 * @param {object|undefined} t - Track entry from trackMap.
 * @param {number} id - Track id, used to find the card's slider.
 * @returns {void}
 */
function cancelTrackFade(t, id) {
  if (!t?.gainNode) return;
  const card   = document.querySelector(`.track-card[data-track-id="${id}"]`);
  const slider = card?.querySelector('.s-trim');
  const target = slider ? Math.pow(10, parseFloat(slider.value) / 20) : 1;
  const audioCtx = getAudioCtx();
  t.gainNode.gain.cancelScheduledValues(audioCtx.currentTime);
  t.gainNode.gain.value = target;
}

/** MARK: STOP ALL PLAYBACK */

/**
 * Pause everything: the video, every track, and the replacement OA (which follows
 * the transport). Cancels any scheduled gain fades (restoring each track to its
 * slider value), cancels any in-flight rAF frame so startWaveformAnimation can
 * restart cleanly, and resets the preview state and the active card's Preview
 * button.
 * @returns {void}
 */
function pauseAll() {
  if (!videoEl.paused) { videoEl.pause(); btnPlay.innerHTML = '<span>&#x25B6;</span>'; }
  trackMap.forEach(({ audio }) => { if (!audio.paused) audio.pause(); });
  if (oaReplaceAudio && !oaReplaceAudio.paused) oaReplaceAudio.pause();
  trackMap.forEach((t, id) => cancelTrackFade(t, id));
  if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
  if (isPreviewing) {
    isPreviewing = false;
    const btn = getActivePreviewBtn();
    if (btn) {
      btn.innerHTML = `<svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor"><polygon points="0,0 9,5 0,10"/></svg> Preview`;
      btn.classList.remove('previewing');
    }
  }
}

/** MARK: VIDEO — player, scrub bar, and OA-surface DOM references. */

const videoDrop     = document.getElementById('videoDrop');
const videoEl       = document.getElementById('videoEl');
const videoRemove   = document.getElementById('videoRemove');
const videoFilename = document.getElementById('videoFilename');
const videoFileInput= document.getElementById('videoFileInput');
const scrub         = document.getElementById('scrub');
const scrubFill     = document.getElementById('scrubFill');
const scrubHead     = document.getElementById('scrubHead');
const scrubTime     = document.getElementById('scrubTime');
const scrubDimL     = document.getElementById('scrubDimL');
const scrubDimR     = document.getElementById('scrubDimR');
const scrubTrimIn   = document.getElementById('scrubTrimIn');
const scrubTrimOut  = document.getElementById('scrubTrimOut');
const scrubTrimTip  = document.getElementById('scrubTrimTip');
const scrubRegion   = document.getElementById('scrubRegion');
const oaWrap        = document.getElementById('oaWrap');
const oaDimL        = document.getElementById('oaDimL');
const oaDimR        = document.getElementById('oaDimR');
const oaTrimIn      = document.getElementById('oaTrimIn');
const oaTrimOut     = document.getElementById('oaTrimOut');
const oaTrimTip     = document.getElementById('oaTrimTip');
const oaRegion      = document.getElementById('oaRegion');
/**
 * Music Start marker elements — one on each surface, both driven from the same
 * musicStartTime. The readout is a single overlay on the PICTURE (shared by both
 * markers), not a per-handle tooltip: the frame you land on is the decision, so the
 * time belongs next to it.
 */
const scrubMusicMarker   = document.getElementById('scrubMusicMarker');
const oaMusicMarker      = document.getElementById('oaMusicMarker');
const videoMarkerReadout = document.getElementById('videoMarkerReadout');
const btnPlay       = document.getElementById('btnPlay');
const btnRestart    = document.getElementById('btnRestart');
const btnLoop       = document.getElementById('btnLoop');
const statusText    = document.getElementById('statusText');
// Honour the root flag: keep the status element in the DOM (so all the
// statusText.textContent writes keep working) but hide it unless flagged on.
statusText.style.display = SHOW_STATUS_TEXT ? '' : 'none';

const errorModal = document.getElementById('errorModal');

/**
 * Most recent error captured for the Contact Support form (single snapshot,
 * in-memory only — cleared on app relaunch). Every error flows through showError(),
 * so recording it there covers all call sites with no extra wiring. The support
 * form's "Paste most recent error" button reads this on demand.
 * @type {{timestamp: string, msg: string, detail: (string|null)}|null}
 */
let lastDiagnosticError = null;

/**
 * Overt error feedback: pops the error modal AND updates the (possibly hidden)
 * status line, so behaviour stays consistent whether or not SHOW_STATUS_TEXT is on.
 * Raw technical detail (e.g. FFmpeg stderr) goes in a disclosure that starts
 * collapsed each time, only shown when it adds something beyond the friendly
 * message. Also records the error to lastDiagnosticError.
 * @param {string} msg - Friendly, user-facing message.
 * @param {string} [detail] - Raw technical detail for the collapsed disclosure.
 * @returns {void}
 */
function showError(msg, detail) {
  lastDiagnosticError = { timestamp: new Date().toISOString(), msg, detail: detail ?? null };
  statusText.textContent = msg;
  document.getElementById('errorMessage').textContent = msg;

  const detailEl = document.getElementById('errorDetail');
  if (detail && detail !== msg) {
    document.getElementById('errorDetailText').textContent = detail;
    detailEl.open = false;
    detailEl.style.display = '';
  } else {
    detailEl.style.display = 'none';
  }
  if (!errorModal.open) errorModal.showModal();
}
// Copy button lives inside the <summary>; preventDefault keeps the click from
// toggling the <details>.
document.getElementById('btnErrorCopy').addEventListener('click', e => {
  e.preventDefault();
  navigator.clipboard.writeText(document.getElementById('errorDetailText').textContent);
});
document.getElementById('btnErrorOk').addEventListener('click', () => errorModal.close());
document.getElementById('btnErrorClose').addEventListener('click', () => errorModal.close());

const noticeModal = document.getElementById('noticeModal');

/**
 * Positive counterpart to showError: same modal treatment, custom title, no
 * technical-detail disclosure, and nothing recorded to lastDiagnosticError.
 * @param {string} title - Modal title.
 * @param {string} msg - Message body.
 * @returns {void}
 */
function showNotice(title, msg) {
  document.getElementById('noticeTitle').textContent = title;
  document.getElementById('noticeMessage').textContent = msg;
  if (!noticeModal.open) noticeModal.showModal();
}
document.getElementById('btnNoticeOk').addEventListener('click', () => noticeModal.close());
document.getElementById('btnNoticeClose').addEventListener('click', () => noticeModal.close());

const expiredModal = document.getElementById('expiredModal');

/**
 * Show the expired-subscription modal — a save was attempted without a live
 * subscription. Carries the launch notice's copy and Manage Account action as an
 * in-app modal (shown by requireCanSave()).
 * @param {string} msg - Message explaining why saving is blocked.
 * @returns {void}
 */
function showExpired(msg) {
  document.getElementById('expiredMessage').textContent = msg;
  if (!expiredModal.open) expiredModal.showModal();
}
document.getElementById('btnExpiredClose').addEventListener('click', () => expiredModal.close());
// Manage: same SSO handoff as the account modal.
document.getElementById('btnExpiredManage').addEventListener('click', () => {
  window.ezl.openAccountPortal();
  expiredModal.close();
});

/**
 * MARK: SETTINGS / USER / HELP MODALS — these three live in their own files
 * (src/partials/*.html) for ease of editing. Open via showModal(), close via
 * .close(); Escape works natively (dialog 'cancel' event). Settings persistence is
 * backed by window.ezl.getSettings()/setSettings() (main-process JSON store in
 * userData); the modal repopulates from the store every time it opens, so edits that
 * end with Cancel/Escape are simply discarded. Integrations (Frame.io/DISCO) and the
 * App update toggle stay visual-only for now: they depend on OAuth and the updater
 * flows that aren't built, so they're not bound.
 */

/**
 * Inject the settings/user/help modal partials and wire their behaviour. The
 * renderer can't fetch() local HTML under file:// + contextIsolation, so the main
 * process reads each partial (window.ezl.loadPartial) and the returned markup is
 * injected here. IMPORTANT: style tags injected via innerHTML DO apply, but script
 * tags do NOT run — so all page behaviour is wired after injection, by the init*
 * functions. initHelpModal is awaited because it fetches and renders help.md into
 * the help.html shell.
 * @returns {Promise<void>}
 */
async function mountModals() {
  const host  = document.getElementById('modalHost');
  const names = ['settings', 'user', 'help'];
  const htmls = await Promise.all(names.map(n => window.ezl.loadPartial(n)));
  host.innerHTML = htmls.join('\n');
  initSettingsModal();
  initUserModal();
  await initHelpModal();
}

/**
 * Wire backdrop click-to-close on a dialog. The dialogs have padding:0, so a click
 * whose target is the dialog element itself can only have landed on the ::backdrop.
 * Armed on pointerdown (not just click) so a drag that STARTS inside the modal —
 * e.g. selecting text in an input — and releases outside doesn't count as an outside
 * click and dismiss the modal.
 * @param {HTMLDialogElement} modal - Dialog to wire.
 * @param {function(): void} [close] - Close action; defaults to modal.close().
 * @returns {void}
 */
function closeOnBackdropClick(modal, close = () => modal.close()) {
  let armedOnBackdrop = false;
  modal.addEventListener('pointerdown', (e) => { armedOnBackdrop = (e.target === modal); });
  modal.addEventListener('click', (e) => {
    if (armedOnBackdrop && e.target === modal) close();
    armedOnBackdrop = false;
  });
}

/** MARK: Settings modal */

/**
 * Wire the Settings modal: default save folder (native picker + reset-to-Downloads),
 * layback filename affix with live preview, export metadata fields, cover artwork
 * (click or drag-and-drop), and the auto-update switch. Populates from the persisted
 * store on every open; Done (or clicking outside) saves, Cancel/Escape discards.
 * @returns {void}
 */
function initSettingsModal() {
  const modal = document.getElementById('settingsModal');

  const folderPath   = document.getElementById('setFolderPath');
  const folderChange = document.getElementById('setFolderChange');
  const folderReset  = document.getElementById('setFolderReset');
  const text         = document.getElementById('setNameText');
  const before       = document.getElementById('setNameBefore');
  const after        = document.getElementById('setNameAfter');
  const preview      = document.getElementById('setNamePreview');
  const metaArtist   = document.getElementById('setMetaArtist');
  const metaAlbum    = document.getElementById('setMetaAlbum');
  const metaComposer = document.getElementById('setMetaComposer');
  const metaGrouping = document.getElementById('setMetaGrouping');
  const metaComments = document.getElementById('setMetaComments');
  const artDrop      = document.getElementById('setArtDrop');
  const artInput     = document.getElementById('setArtInput');
  const artName      = document.getElementById('setArtName');
  const artPreview   = document.getElementById('ArtPreviewImg');  // <img> for the cover artwork preview
  const setArtPreview = document.getElementById('setArtPreview'); // div that wraps the <img>
  const artRemove     = document.getElementById('setArtRemove');  // × overlay that clears the artwork
  const autoUpdate    = document.getElementById('setAutoUpdate'); // .switch — app.checkUpdatesOnLaunch

  /*
   * Working state for controls whose value isn't simply an input's .value:
   * - chosenFolder: the absolute path picked via the native dialog this session.
   * - folderChanged: did the user actually pick a folder? If not, defaultSaveFolder is
   *   OMITTED from the save patch so the store keeps its "unset → follow Downloads"
   *   intent (the main-process merge preserves any key absent from the patch).
   * - folderIsReset: did the change come from the Reset button rather than the native
   *   picker? If so defaultSaveFolder is sent EXPLICITLY as null (rather than the
   *   resolved Downloads string) so the stored intent stays "follow Downloads",
   *   portable across machines — see the null comment in settings-store.js's DEFAULTS.
   * - downloadsPath: the live system Downloads path, fetched on populate(); used to
   *   decide whether the Reset button should show at all.
   * - chosenArtwork: absolute path to the cover image, or null.
   */
  let chosenFolder  = null;
  let folderChanged = false;
  let folderIsReset = false;
  let downloadsPath = null;
  let chosenArtwork = null;

  /**
   * Show the Reset button only when the current folder isn't already Downloads.
   * @returns {void}
   */
  function updateFolderResetVisibility() {
    folderReset.style.display = (chosenFolder === downloadsPath) ? 'none' : 'inline-block';
  }

  /**
   * Rebuild the live filename example from the affix text and Before/After toggle,
   * using "My Song" as the sample track name. Built with textContent (not innerHTML)
   * so a literal "<" in the affix can't break markup.
   * @returns {void}
   */
  function updateNamePreview() {
    const extra = text.value;
    const base  = 'My Song';
    const name  = before.classList.contains('on') ? `${extra}${base}` : `${base}${extra}`;
    preview.textContent = 'Example: ';
    const b = document.createElement('b');
    b.textContent = `${name}.mp4`;
    preview.appendChild(b);
  }

  /**
   * Swap between the artwork preview (image chosen) and the empty dropzone. Display
   * is reset to '' (not 'block') so the stylesheet's flex layout centers the image
   * or the "+" glyph.
   * @returns {void}
   */
  function renderArtwork() {
    if (chosenArtwork) {
      artDrop.style.display = 'none';
      artName.textContent = chosenArtwork.split('/').pop();
      artPreview.src = chosenArtwork ? `file://${chosenArtwork}` : '';
      setArtPreview.style.display = '';
    } else {
      setArtPreview.style.display = 'none';
      artDrop.style.display = '';
      artDrop.textContent = '+';
      artName.textContent = 'JPEG and PNG files supported.';
    }
  }

  /**
   * Pull current settings from the store and paint every field. Runs on each open so
   * the modal always reflects what's persisted (and discards any uncommitted prior
   * edits). defaultSaveFolder arrives already resolved to ~/Downloads if unset.
   * @returns {Promise<void>}
   */
  async function populate() {
    const s = await window.ezl.getSettings();
    chosenFolder  = s.files.defaultSaveFolder;
    folderChanged = false;
    folderIsReset = false;
    folderPath.textContent = chosenFolder;
    downloadsPath = await window.ezl.getDownloadsPath();
    updateFolderResetVisibility();

    text.value = s.files.nameAffix.text;
    const pos = s.files.nameAffix.position;
    before.classList.toggle('on', pos !== 'after');
    after.classList.toggle('on',  pos === 'after');

    metaArtist.value   = s.metadata.artist;
    metaAlbum.value    = s.metadata.album;
    metaComposer.value = s.metadata.composer;
    metaGrouping.value = s.metadata.grouping;
    metaComments.value = s.metadata.comments;
    chosenArtwork      = s.metadata.artworkPath;

    setAutoUpdateOn(s.app.checkUpdatesOnLaunch);

    renderArtwork();
    updateNamePreview();
  }

  /**
   * Drive the auto-update switch's visual and ARIA state. Only takes effect on the
   * NEXT launch — updater.js reads the setting when it starts, and a running app has
   * already made its decision.
   * @param {boolean} on - Desired switch state.
   * @returns {void}
   */
  function setAutoUpdateOn(on) {
    autoUpdate.classList.toggle('on', !!on);
    autoUpdate.setAttribute('aria-checked', on ? 'true' : 'false');
  }

  autoUpdate.addEventListener('click', () => setAutoUpdateOn(!autoUpdate.classList.contains('on')));
  autoUpdate.addEventListener('keydown', (e) => {
    if (e.key !== ' ' && e.key !== 'Enter') return;
    e.preventDefault(); // Space would scroll the modal
    setAutoUpdateOn(!autoUpdate.classList.contains('on'));
  });

  /**
   * Build the settings patch and persist it. defaultSaveFolder is only included if
   * the user picked one this session (see the folderChanged note above); everything
   * else round-trips by value.
   * @returns {Promise<void>}
   */
  async function save() {
    const filesPatch = {
      nameAffix: {
        text: text.value,
        position: before.classList.contains('on') ? 'before' : 'after',
      },
    };
    if (folderChanged) filesPatch.defaultSaveFolder = folderIsReset ? null : chosenFolder;

    await window.ezl.setSettings({
      files: filesPatch,
      metadata: {
        artist:   metaArtist.value.trim(),
        album:    metaAlbum.value.trim(),
        composer: metaComposer.value.trim(),
        grouping: metaGrouping.value.trim(),
        comments: metaComments.value.trim(),
        artworkPath: chosenArtwork,
      },
      app: { checkUpdatesOnLaunch: autoUpdate.classList.contains('on') },
    });
  }

  // Open: repopulate first so stale DOM values never flash, then show. Fit runs
  // AFTER showModal — populate() ran while the dialog was hidden (zero layout width).
  document.getElementById('btnSettings').addEventListener('click', async () => {
    if (modal.open) return;
    await populate();
    modal.showModal();
    fitFolderPath(folderPath, chosenFolder);
  });


  // Cancel / Escape: discard — next open repopulates from the store.
  ['btnSettingsCancel'].forEach(id => {
    document.getElementById(id).addEventListener('click', () => modal.close());
  });

  /**
   * Persist, then close. On a write failure surface the error modal rather than
   * closing silently, so the user knows their settings didn't save. Clicking outside
   * the modal is equivalent to Done — it saves, not discards.
   * @returns {Promise<void>}
   */
  async function saveAndClose() {
    try {
      await save();
      modal.close();
    } catch (err) {
      showError("Couldn't save settings.", err && err.message);
    }
  }
  document.getElementById('btnSettingsDone').addEventListener('click', saveAndClose);
  closeOnBackdropClick(modal, saveAndClose);

  // Folder picker (native dialog in the main process). Cancelling keeps the current
  // value; the modal is already open here, so the path fit is safe to measure.
  folderChange.addEventListener('click', async () => {
    const picked = await window.ezl.showFolderPicker();
    if (!picked) return;
    chosenFolder = picked;
    folderChanged = true;
    folderIsReset = false;
    fitFolderPath(folderPath, picked);
    updateFolderResetVisibility();
  });

  /*
   * Reset: returns the folder to the system Downloads path. Sends defaultSaveFolder
   * as null (see the folderIsReset note above) rather than the resolved path, so the
   * stored intent stays "follow Downloads" instead of pinning today's Downloads
   * location.
   */
  folderReset.addEventListener('click', () => {
    chosenFolder = downloadsPath;
    folderChanged = true;
    folderIsReset = true;
    fitFolderPath(folderPath, chosenFolder);
    updateFolderResetVisibility();
  });

  /*
   * Cover artwork: the dropzone proxies clicks to the hidden file input; on
   * selection the File resolves to an absolute path via webUtils and is stored.
   * Re-clicking replaces it. renderArtwork() swaps setArtDrop out for setArtPreview
   * once artwork is chosen, so the preview needs its own listener onto the same
   * input or it becomes a dead end.
   */
  artDrop.addEventListener('click', () => artInput.click());
  setArtPreview.addEventListener('click', () => artInput.click());

  /*
   * Remove (×): clears the artwork and reverts to the empty dropzone. Stop
   * propagation so this click doesn't also bubble up to setArtPreview's listener
   * and reopen the picker.
   */
  artRemove.addEventListener('click', (e) => {
    e.stopPropagation();
    chosenArtwork = null;
    renderArtwork();
  });
  artInput.addEventListener('change', () => {
    const file = artInput.files[0];
    if (!file) return;
    chosenArtwork = window.ezl.getFilePath(file);
    artInput.value = '';                 // reset so picking the same file again still fires change
    renderArtwork();
  });

  /**
   * Accept a dropped artwork file: validate JPEG/PNG, resolve to a path, store,
   * render — mirroring the artInput change path. Drag-and-drop needs its own wiring
   * because the document-level drop handler preventDefaults file drops. Drops land
   * on either the empty dropzone or an existing preview (to replace it).
   * @param {File|undefined} file - Dropped file.
   * @returns {void}
   */
  function acceptArtworkFile(file) {
    if (!file) return;
    if (file.type !== 'image/jpeg' && file.type !== 'image/png') {
      showError('Unsupported image — cover artwork must be a JPEG or PNG');
      return;
    }
    chosenArtwork = window.ezl.getFilePath(file);
    renderArtwork();
  }
  [artDrop, setArtPreview].forEach(zone => {
    zone.addEventListener('dragover',  e => { e.preventDefault(); zone.classList.add('drag-over'); });
    zone.addEventListener('dragleave', e => { if (!zone.contains(e.relatedTarget)) zone.classList.remove('drag-over'); });
    zone.addEventListener('drop', e => {
      e.preventDefault();
      e.stopPropagation();
      zone.classList.remove('drag-over');
      acceptArtworkFile(e.dataTransfer.files[0]);
    });
  });

  // Live filename preview + Before/After toggle.
  text.addEventListener('input', updateNamePreview);
  [before, after].forEach(btn => btn.addEventListener('click', () => {
    before.classList.toggle('on', btn === before);
    after.classList.toggle('on',  btn === after);
    updateNamePreview();
  }));
}

/** MARK: User Modal */

/**
 * Wire the account (user) modal: populates identity/plan/status rows from the
 * verified entitlement, refreshes the session on each open, and handles Manage
 * Account and Sign Out.
 * @returns {void}
 */
function initUserModal() {
  const modal = document.getElementById('userModal');

  /**
   * Fill every row from the verified entitlement — nothing in the modal is
   * hardcoded. The session-notice banner shows the offline fallback, an expired
   * trial/subscription, or a trial in its final week (offline wins) — same copy as
   * the launch-gate notice. The name prefers a real display name and falls back to
   * the email's local-part so a stale hardcoded name is never shown; avatar initials
   * derive from whichever is used. Owner-only rows are hidden for members (via
   * style.display, not the hidden attribute: .field's display:flex wins over the
   * UA's [hidden] rule). Seats only show on a multi-seat account — solo accounts
   * would just read "1 of 1 used", and older cached payloads lack the fields.
   * @returns {void}
   */
  function populateUserModal() {
    const banner = document.getElementById('userNoticeBanner');
    const notice = sessionNotice();
    banner.hidden = !notice;
    if (notice) banner.textContent = notice.msg;

    const email = entitlement.email || '';
    const name  = entitlement.display_name || email.split('@')[0] || '—';
    const initials = name.trim().split(/[\s._-]+/).filter(Boolean)
      .slice(0, 2).map(p => p[0]).join('').toUpperCase() || '—';

    document.getElementById('userAvatar').textContent = initials;
    document.getElementById('userName').textContent   = name;
    document.getElementById('userEmail').textContent  = email;

    const isOwner = entitlement.role === 'owner';
    const roleEl  = document.getElementById('userRole');
    roleEl.textContent = isOwner ? 'Account owner' : 'Member';
    roleEl.classList.toggle('member', !isOwner);
    document.getElementById('btnManageAccount').style.display = isOwner ? '' : 'none';

    const accountName = entitlement.account_name || '';
    const showAccount = accountName && accountName !== email;
    document.getElementById('rowAccount').style.display = showAccount ? '' : 'none';
    document.getElementById('userAccount').textContent  = accountName || '—';
    document.getElementById('userPlan').textContent   = describePlan();
    document.getElementById('userStatus').textContent = describeStatus();

    document.querySelectorAll('#userModal .field.admin').forEach((el) => {
      el.style.display = isOwner ? '' : 'none';
    });

    const seats = entitlement.seats ?? 1;
    const used  = entitlement.seats_used ?? 1;
    if (isOwner && seats > 1) {
      document.getElementById('userSeats').textContent = `${used} of ${seats} used`;
    } else {
      document.getElementById('rowSeats').style.display = 'none';
    }
  }

  /*
   * Re-read the session on each open so a subscription change lands immediately,
   * without waiting for the 24h background refresh. This is also what live-updates
   * the offline banner: connectivity coming back (or dropping) is reflected the
   * next time the modal opens.
   */
  document.getElementById('btnAccount').addEventListener('click', async () => {
    if (!entitlement) { showAuthGate(); return; }
    const session = await window.ezl.authSession();
    if (session.loggedIn) {
      sessionOffline = !!session.offline;
      setEntitlement(session.payload);
    }
    populateUserModal();
    if (!modal.open) modal.showModal();
  });

  document.getElementById('btnUserClose').addEventListener('click', () => modal.close());
  closeOnBackdropClick(modal);

  // Account editing lives on the web portal; the app just opens it in the browser.
  document.getElementById('btnManageAccount').addEventListener('click', () => {
    window.ezl.openAccountPortal();
  });

  // Real sign-out: drop the tokens in main, clear local state, and fall back to the gate.
  document.getElementById('btnUserSignOut').addEventListener('click', async () => {
    await window.ezl.authLogout();
    sessionOffline = false;
    setEntitlement(null);
    modal.close();
    showAuthGate();
  });
}

/** MARK: Help modal */

/**
 * Tiny, purpose-built markdown-to-HTML converter for the Help modal. It deliberately
 * handles ONLY the subset of syntax documented at the top of src/partials/help.md —
 * it is NOT a general markdown parser. Supported: ## sections with optional
 * {#id | Nav Label} suffixes, ### subheadings, ::: raw-HTML passthrough blocks,
 * > callouts, numbered and bulleted lists, Q:/A: FAQ pairs, and paragraphs with an
 * optional {lead}/{dim} class prefix. Inline formatting HTML-escapes FIRST (so copy
 * can safely contain literal < > &), then converts `chip`, **bold**, and
 * {icon:user}/{icon:gear} tokens — the icons are the same footer-button SVGs used by
 * #btnAccount/#btnSettings, so the help copy can point at "the user icon" / "the
 * gear icon" and readers see the literal glyph instead of a generic emoji. The nav
 * is generated from the ## headings, so it can never drift out of sync with the
 * content; the first nav link is active by default and each section after the first
 * gets a top divider.
 * @param {string} md - Help markdown source.
 * @returns {{nav: string, body: string}} Left-rail link markup and section markup.
 */
function renderHelpMarkdown(md) {
  const ICONS = {
    user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
    gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
  };

  const inline = (s) => s
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/`([^`]+)`/g, '<span class="kbd">$1</span>')
    .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
    .replace(/\{icon:(user|gear)\}/g, (_, name) => `<span class="icon-inline">${ICONS[name]}</span>`);

  const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');

  const lines = md.replace(/\r\n/g, '\n').split('\n');
  const nav = [];          // { id, label } per section
  const sections = [];     // { id, html } per section
  let cur = null;          // section currently being built
  let i = 0;

  const flush = () => { if (cur) sections.push(cur); };
  const add   = (html) => { if (cur) cur.html += html; };

  while (i < lines.length) {
    const t = lines[i].trim();

    // Blank lines and the leading <!-- ... --> doc-comment block are skipped.
    if (!t) { i++; continue; }
    if (t.startsWith('<!--')) { while (i < lines.length && !lines[i].includes('-->')) i++; i++; continue; }

    // ## Section {#id | Nav Label} — opens a new section + nav entry (### handled below)
    let m = t.match(/^##\s+(.+?)\s*(?:\{([^}]*)\})?\s*$/);
    if (m && !t.startsWith('###')) {
      flush();
      const title = m[1];
      let id = slug(title), label = title;
      if (m[2]) for (const part of m[2].split('|').map(s => s.trim())) {
        if (part.startsWith('#')) id = part.slice(1).trim();   // #anchor-id
        else if (part)           label = part;                  // nav label
      }
      nav.push({ id, label });
      cur = { id, html: `<h2>${inline(title)}</h2>` };
      i++; continue;
    }

    // ### Subheading
    m = t.match(/^###\s+(.+)$/);
    if (m) { add(`<h3>${inline(m[1])}</h3>`); i++; continue; }

    // ::: raw HTML passthrough ::: (verbatim, not escaped)
    if (t === ':::') {
      i++; let raw = '';
      while (i < lines.length && lines[i].trim() !== ':::') { raw += lines[i] + '\n'; i++; }
      i++;                       // consume the closing :::
      add(raw); continue;
    }

    // > callout (consecutive > lines fold into one box)
    if (t.startsWith('>')) {
      const buf = [];
      while (i < lines.length && lines[i].trim().startsWith('>')) {
        buf.push(lines[i].trim().replace(/^>\s?/, '')); i++;
      }
      add(`<div class="callout"><p>${inline(buf.join(' '))}</p></div>`); continue;
    }

    // Numbered list (consecutive  n.  lines)
    if (/^\d+\.\s/.test(t)) {
      const items = [];
      while (i < lines.length && /^\d+\.\s/.test(lines[i].trim())) {
        items.push(`<li>${inline(lines[i].trim().replace(/^\d+\.\s/, ''))}</li>`); i++;
      }
      add(`<ol>${items.join('')}</ol>`); continue;
    }

    // Bullet list (consecutive  -  lines)
    if (/^-\s/.test(t)) {
      const items = [];
      while (i < lines.length && /^-\s/.test(lines[i].trim())) {
        items.push(`<li>${inline(lines[i].trim().replace(/^-\s/, ''))}</li>`); i++;
      }
      add(`<ul>${items.join('')}</ul>`); continue;
    }

    // FAQ pairs
    m = t.match(/^Q:\s*(.+)$/);
    if (m) { add(`<p class="faq-q">${inline(m[1])}</p>`); i++; continue; }
    m = t.match(/^A:\s*(.+)$/);
    if (m) { add(`<p class="faq-a">${inline(m[1])}</p>`); i++; continue; }

    // Paragraph, with optional {lead}/{dim} class prefix
    let cls = '', text = t;
    m = t.match(/^\{(lead|dim)\}\s*(.*)$/);
    if (m) { cls = ` class="${m[1]}"`; text = m[2]; }
    add(`<p${cls}>${inline(text)}</p>`); i++;
  }
  flush();

  const navHtml  = nav.map((n, idx) =>
    `<a${idx === 0 ? ' class="active"' : ''} data-to="${n.id}">${inline(n.label)}</a>`).join('\n');
  const bodyHtml = sections.map((s, idx) =>
    `<section id="${s.id}">${idx > 0 ? '<div class="divider"></div>' : ''}${s.html}</section>`).join('\n');

  return { nav: navHtml, body: bodyHtml };
}

/**
 * Wire the Help modal: renders the help copy (src/partials/help.md) into the empty
 * help.html shell, wires the Contact Support form, and sets up nav scrollspy.
 * innerHTML is safe here ONLY because the markdown is app-authored and shipped in
 * the bundle — never route user-supplied content through renderHelpMarkdown() or
 * these assignments.
 * @returns {Promise<void>}
 */
async function initHelpModal() {
  const modal = document.getElementById('helpModal');

  try {
    const md = await window.ezl.loadPartial('help-md');
    const { nav, body } = renderHelpMarkdown(md);
    modal.querySelector('.nav').innerHTML = nav;
    modal.querySelector('.content').innerHTML = body;
  } catch (e) {
    console.error('Help content failed to load:', e);
    modal.querySelector('.content').innerHTML = '<section><p>Help content failed to load.</p></section>';
  }

  document.getElementById('btnHelp').addEventListener('click', () => {
    populateSupportForm();           // refresh email/plan/app each open (entitlement may change)
    if (!modal.open) modal.showModal();
  });
  document.getElementById('btnHelpClose').addEventListener('click', () => modal.close());
  closeOnBackdropClick(modal);

  /*
   * Contact Support form. The form markup was injected from help.md above, so its
   * elements only exist now. Paste/submit handlers are wired once; the read-only
   * context fields are (re)populated on each open via populateSupportForm().
   */
  const supPaste = modal.querySelector('#supPasteError');
  if (supPaste) {
    supPaste.addEventListener('click', () => {
      const box = modal.querySelector('#supError');
      if (!lastDiagnosticError) {
        box.value = 'No errors recorded this session.';
        return;
      }
      const { timestamp, msg, detail } = lastDiagnosticError;
      box.value = `[${timestamp}]\n${msg}` + (detail ? `\n\n${detail}` : '');
    });
  }
  const supSubmit = modal.querySelector('#supSubmit');
  if (supSubmit) {
    // One send in flight at a time. The disabled attribute alone isn't enough:
    // the online/offline handler below rewrites disabled on every event, so a
    // reconnection mid-send would re-arm the button under a pending request.
    let supSending = false;
    /*
     * Submit sends to the licensing API's POST /support via main (support-request
     * IPC). The server derives who it's from (Reply-To, plan) from the auth token,
     * so the payload is only: message + optional error dump + a diagnostics bag.
     * Validation + outcome wording live in support-form.js (unit-tested); this
     * handler owns only the DOM and the IPC call. On a validation failure both
     * sentences go in showError's first argument — its second argument is the
     * collapsed technical-detail disclosure, not a subtitle.
     */
    supSubmit.addEventListener('click', async () => {
      const message = modal.querySelector('#supIssue').value.trim();
      const invalid = SupportForm.validateMessage(message);
      if (invalid) {
        showError(`${invalid.title} ${invalid.message}`);
        return;
      }
      supSending = true;
      supSubmit.disabled = true;  // one in flight at a time (server also rate-limits)
      try {
        const result = await window.ezl.sendSupportRequest({
          message,
          error: modal.querySelector('#supError').value.trim() || null,
          diagnostics: { app: modal.querySelector('#supApp').textContent },
        });
        const failure = SupportForm.resultMessage(result);
        if (!failure) {
          showNotice('Message sent', 'Your support request is on its way — we’ll get back to you soon.');
          modal.querySelector('#supIssue').value = '';
          modal.querySelector('#supError').value = '';
        } else {
          showError(`${failure.title} ${failure.message}`);
        }
      } catch (e) {
        console.error('Support submission failed:', e);
        // The exception text is genuinely technical → it goes in the disclosure.
        showError('Support submission failed. Something went wrong — please try again.',
                  e?.message ?? String(e));
      } finally {
        supSending = false;
        // Re-enable only if still online — offline may have disabled the whole
        // form while the send was in flight.
        supSubmit.disabled = !navigator.onLine;
      }
    });

    /*
     * Offline handling: the whole form disables with a plain reason — a form that
     * can't submit shouldn't look live. navigator.onLine is trusted only in the
     * false direction (no network interface at all); connected-but-unreachable
     * (e.g. on the internet but off the tailnet) is still caught at send time by
     * the network branch above. Disabling never clears the fields — a draft typed
     * before the connection dropped survives until it can be sent.
     */
    const supForm = supSubmit.closest('form');
    if (supForm) {
      const supOfflineNote = document.createElement('div');
      supOfflineNote.className = 'support-label';
      supOfflineNote.style.display = 'none';
      supOfflineNote.textContent = 'You’re offline — support needs a connection.';
      supForm.appendChild(supOfflineNote);
      const supSyncOnline = () => {
        const off = !navigator.onLine;
        supForm.querySelectorAll('textarea, button').forEach((el) => { el.disabled = off; });
        if (supSending) supSubmit.disabled = true;  // never re-arm Send mid-flight
        supForm.style.opacity = off ? '0.55' : '';
        supOfflineNote.style.display = off ? '' : 'none';
      };
      window.addEventListener('online', supSyncOnline);
      window.addEventListener('offline', supSyncOnline);
      supSyncOnline();
    }
  }

  // Scrollspy: light the nav item for whichever section is in view; clicks jump to a section
  const links   = [...modal.querySelectorAll('.nav a')];
  const content = modal.querySelector('.content');
  links.forEach(a => a.addEventListener('click', () => {
    modal.querySelector('#' + a.dataset.to).scrollIntoView({ block: 'start' });
  }));
  const obs = new IntersectionObserver(entries => {
    entries.forEach(e => {
      if (e.isIntersecting) {
        links.forEach(l => l.classList.toggle('active', l.dataset.to === e.target.id));
      }
    });
  }, { root: content, rootMargin: '-10% 0px -75% 0px' });
  modal.querySelectorAll('section').forEach(s => obs.observe(s));
}

/** Human-readable OS names for the support form's App line. */
const PLATFORM_LABELS = { darwin: 'macOS', win32: 'Windows', linux: 'Linux' };

/**
 * Fill the Contact Support form's read-only context (Email / Plan / App). Called on
 * each Help open so it reflects the current sign-in. Identity comes from the
 * already-verified entitlement payload; version + OS come from the get-app-info IPC.
 * No-op when the form isn't present (e.g. help failed to load).
 * @returns {Promise<void>}
 */
async function populateSupportForm() {
  const emailEl = document.getElementById('supEmail');
  const planEl  = document.getElementById('supPlan');
  const appEl   = document.getElementById('supApp');
  if (!emailEl) return;

  if (entitlement) {
    emailEl.textContent = entitlement.email || '—';
    planEl.textContent  = [entitlement.tier, entitlement.status].filter(Boolean).join(' · ') || '—';
  } else {
    emailEl.textContent = 'Not signed in';
    planEl.textContent  = '—';
  }

  try {
    const info = await window.ezl.getAppInfo();
    const os = PLATFORM_LABELS[info.platform] || info.platform;
    appEl.textContent = `${os} ${info.osVersion} · v${info.version}`;
  } catch {
    appEl.textContent = '—';
  }
}

mountModals(); // inject + wire the three modals on startup

const toastHost = document.getElementById('toastHost');

/**
 * Transient, non-blocking confirmation toast (bottom-right). Used for low-stakes
 * successes like quick-save, where a full modal would be too heavy. The message is
 * written via textContent so HTML in filenames stays inert. A forced reflow (via
 * requestAnimationFrame) makes the transition run from the hidden start state; after
 * the timeout the toast fades out and is removed from the DOM once the transition
 * finishes.
 * @param {string} msg - Message to show.
 * @param {number} [ms=3000] - Auto-dismiss delay in milliseconds.
 * @returns {void}
 */
function showToast(msg, ms = 3000) {
  const toast = document.createElement('div');
  toast.className = 'toast';
  toast.innerHTML =
    `<span class="toast-check"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" ` +
    `stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">` +
    `<polyline points="20 6 9 17 4 12"/></svg></span>` +
    `<span class="toast-msg"></span>`;
  toast.querySelector('.toast-msg').textContent = msg;
  toastHost.appendChild(toast);
  requestAnimationFrame(() => toast.classList.add('show'));
  setTimeout(() => {
    toast.classList.remove('show');
    toast.addEventListener('transitionend', () => toast.remove(), { once: true });
  }, ms);
}

let videoLoaded = false;
let currentVideoPath = null; // native FS path used for export

// Preview-proxy state (see the VIDEO PREVIEW PROXY block below).
let _videoLoadToken = 0;            // bumps on every load/unload — stales in-flight proxy results
let _usingVideoProxy = false;       // videoEl.src is the proxy — don't re-run detection on it
let _videoProxyRequested = false;   // one transcode per video: loadedmetadata(width 0) and
                                    // error can BOTH fire for the same undecodable file
let _videoProxySwapPending = false; // next loadedmetadata is the proxy swap — keep the trim window

/**
 * MARK: TRIM STATE — video trim window, in seconds. videoTrimEnd === null means
 * "full extent" (= duration), which is also what the backend expects to mean "no
 * out-trim". Per-track trim lives on each trackMap entry as { trimStart, trimEnd }
 * so it survives active-track switches.
 */
let videoTrimStart = 0;
let videoTrimEnd   = null;
let _trimDragging  = false; // true during/just-after a handle drag — suppresses click-to-seek

/**
 * MUSIC START MARKER — the video-timeline moment at which music tracks begin playing
 * (each from its own audio In point). ONE global marker; every track shares it.
 * null = "not placed" → the video In point, which is exactly the pre-marker
 * behaviour: mStart() then reads as vIn() by construction, so every consuming path
 * reduces to the marker-less code. Same idiom as videoTrimEnd === null ("full
 * extent") and trimEndTarget === null ("whole track").
 * @type {number|null}
 */
let musicStartTime = null;

/**
 * Route videoEl through a GainNode so OA level can be normalised in preview. The
 * node starts silent until analysis sets the real gain. createMediaElementSource
 * takes ownership of the element and can only be called once, so this lazy-inits on
 * first video load and the graph is reused indefinitely.
 * @returns {void}
 */
function setupOaAudioGraph() {
  if (oaGainNode) return;
  const ctx  = getAudioCtx();
  oaGainNode = ctx.createGain();
  oaGainNode.gain.value = 0.0;
  ctx.createMediaElementSource(videoEl).connect(oaGainNode);
  oaGainNode.connect(ctx.destination);
}

/** MARK: ORIGINAL AUDIO REPLACEMENT */

/** Audio extensions accepted as an OA replacement (superset of the video importer). */
const OA_AUDIO_EXTS = ['.wav', '.aiff', '.aif', '.aifc', '.flac', '.mp3', '.m4a', '.aac', '.ogg'];

/**
 * Whether a filename has an extension accepted as an OA replacement.
 * @param {string} name - Filename to test.
 * @returns {boolean}
 */
function isOaAudioName(name) {
  return OA_AUDIO_EXTS.some(ext => (name || '').toLowerCase().endsWith(ext));
}

/**
 * Route a replacement audio element through the SAME oaGainNode as the embedded OA,
 * so the ON/OFF toggle and normalisation gain apply with no extra wiring.
 * createMediaElementSource binds once per element, so a fresh element is built each
 * load and the old one torn down first. crossOrigin is set BEFORE src: an
 * ezl-file:// / uncredited source taints the element and its MediaElementSource then
 * outputs silence (same rule as the video + track elements). Volume is seeded from
 * the Master Volume slider.
 * @param {string} playUrl - Object URL of the playable source.
 * @returns {void}
 */
function setupOaReplaceGraph(playUrl) {
  const ctx = getAudioCtx();
  oaReplaceAudio = new Audio();
  oaReplaceAudio.crossOrigin = 'anonymous';
  oaReplaceAudio.src = playUrl;
  oaReplaceAudio.volume = parseInt(document.getElementById('masterVol').value) / 100;
  oaReplaceSrc = ctx.createMediaElementSource(oaReplaceAudio);
  oaReplaceSrc.connect(oaGainNode);
}

/**
 * Load a file as the Original Audio replacement: analyse it exactly like the video's
 * OA (same IPC, same peak-normalisation math to -0.1 dBTP), mute the embedded
 * stream, and play the file through oaGainNode in preview. Export reads the original
 * file (see the export payload). A token supersedes any earlier in-flight load, so a
 * newer drop always wins. The playable source depends on format: Chromium can't
 * decode AIFF and mis-seeks MP3, so those route through the worker's WAV (same
 * helper the tracks use) while native formats stream as-is — measurements always
 * come from the ORIGINAL file, so they're correct either way. The waveform is padded
 * to the video timeline so a shorter replacement shows real audio up front and
 * silence after, instead of stretching to fill (see oaDisplayPeaks). If tracks are
 * loaded, AUTO-MIX lights up — gains can now be recalculated against the new anchor.
 * @param {File} file - The dropped/picked replacement audio file.
 * @returns {Promise<void>}
 */
async function loadOaReplacement(file) {
  if (!videoLoaded) { showError('Load a video first, then replace its audio.'); return; }
  if (!isOaAudioName(file?.name)) {
    showError("That file type isn't supported. Try WAV, AIFF, FLAC, MP3, M4A, AAC, or OGG.");
    return;
  }
  const desc = descriptorFromFile(file);
  if (!desc.path) { showError('That file’s location is unavailable — try dragging it in again.'); URL.revokeObjectURL(desc.url); return; }

  const token = ++oaReplaceToken;
  setupOaAudioGraph();            // ensure oaGainNode exists (silent video load already calls this)
  statusText.textContent = `Analysing ${desc.name}…`;

  let r;
  try { r = await window.ezl.analyzeLoudness(desc.path); }
  catch (err) { if (token === oaReplaceToken) showError('That audio file couldn’t be read.', err?.message); URL.revokeObjectURL(desc.url); return; }
  if (token !== oaReplaceToken) { URL.revokeObjectURL(desc.url); return; } // a newer drop won
  if (r.status !== 'ok') { showError('That audio file couldn’t be read.', r.reason ?? r.error); URL.revokeObjectURL(desc.url); return; }

  const ext = (desc.name.split('.').pop() || '').toLowerCase();
  let playUrl = desc.url;
  if (BACKEND_PREVIEW_EXTS.has(ext)) {
    try {
      const bytes = await window.ezl.getPreviewAudio(desc.path);
      if (token !== oaReplaceToken) { URL.revokeObjectURL(desc.url); return; }
      URL.revokeObjectURL(desc.url); // original blob not needed — play the decoded WAV instead
      playUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' }));
    } catch (e) {
      if (token === oaReplaceToken) showError('That audio couldn’t be prepared for playback.', e?.message ?? String(e));
      URL.revokeObjectURL(desc.url);
      return;
    }
  }

  // Commit: tear down any prior replacement, point OA at the new file.
  resetOaReplaceState();
  oaReplacePath = desc.path;
  oaReplaceName = desc.name;
  oaReplaceUrl  = playUrl;
  oaLufs        = r.lufs;
  oaPeakDbfs    = r.peak_dbfs;
  oaNormGain    = Math.pow(10, (-0.1 - r.peak_dbfs) / 20);
  videoEl.muted = true;
  setupOaReplaceGraph(playUrl);
  if (oaEnabled) oaGainNode.gain.value = oaNormGain;
  setOaWaveform(oaDisplayPeaks(r.peaks, r.duration_sec));
  updateMusicLevelVisibility();
  if (trackMap.size > 0) markAutomixReady();
  updateOaReplaceChip();
  statusText.textContent = `Original Audio replaced with ${desc.name}`;
}

/**
 * Disconnect and drop the replacement element (and revoke its URL), un-muting the
 * embedded stream. Also stales any load still in flight via the token. Does NOT
 * re-analyse the video — used by both the × revert and video unload.
 * @returns {void}
 */
function resetOaReplaceState() {
  oaReplaceToken++;
  if (oaReplaceSrc) { try { oaReplaceSrc.disconnect(); } catch (_) {} oaReplaceSrc = null; }
  if (oaReplaceAudio) { oaReplaceAudio.pause(); oaReplaceAudio.src = ''; oaReplaceAudio = null; }
  if (oaReplaceUrl) { URL.revokeObjectURL(oaReplaceUrl); oaReplaceUrl = null; }
  oaReplacePath = null;
  oaReplaceName = null;
  if (videoEl) videoEl.muted = false;
  updateOaReplaceChip();
}

/**
 * Handle × on the replacement chip: revert to the video's embedded OA (re-analysing
 * it), or clear the OA measurements if the video is silent — in that case the (now
 * empty) panel is kept so Replace stays reachable. Bails if the video changed while
 * the re-analysis ran.
 * @returns {void}
 */
function clearOaReplacement() {
  if (!oaReplacePath) return;
  resetOaReplaceState();
  if (!currentVideoPath || !window.ezl) { updateMusicLevelVisibility(); return; }
  const path = currentVideoPath;
  statusText.textContent = 'Restoring original audio…';
  window.ezl.analyzeLoudness(path).then(r => {
    if (currentVideoPath !== path) return;
    if (r.status === 'ok' && r.peaks && r.peaks.length) {
      oaLufs = r.lufs; oaPeakDbfs = r.peak_dbfs;
      oaNormGain = Math.pow(10, (-0.1 - r.peak_dbfs) / 20);
      if (oaEnabled && oaGainNode) oaGainNode.gain.value = oaNormGain;
      setOaWaveform(r.peaks);
      statusText.textContent = 'Original Audio restored';
    } else {
      oaLufs = null; oaPeakDbfs = null; oaNormGain = 1.0;
      if (oaGainNode) oaGainNode.gain.value = 0.0;
      vofxPeaks = null;
      drawWavePlaceholder(document.getElementById('vofxWave'), 101, '#1A2E48');
      statusText.textContent = 'Original Audio removed';
    }
    updateMusicLevelVisibility();
    if (trackMap.size > 0 && (oaLufs !== null || [...trackMap.values()].some(t => t.lufs !== null))) markAutomixReady();
  }).catch(() => {});
}

/**
 * Show/hide the OA-replacement chip. While a replacement is loaded its filename
 * replaces the "Original Audio" section label; reverting to the embedded OA restores
 * the label.
 * @returns {void}
 */
function updateOaReplaceChip() {
  const chip = document.getElementById('oaReplaceChip');
  if (!chip) return;
  const label = document.getElementById('oaSectionLabel');
  if (oaReplacePath) {
    document.getElementById('oaReplaceName').textContent = oaReplaceName;
    chip.style.display = '';
    if (label) label.style.display = 'none';
  } else {
    chip.style.display = 'none';
    if (label) label.style.display = '';
  }
}

/*
 * Preview sync: the replacement maps 1:1 to the video clock (starts at :00), so its
 * element is kept aligned with the video wherever the active track is aligned. Both
 * helpers below are no-ops when no replacement is loaded.
 */

/**
 * Start the replacement together with the video from a given time. When the video
 * begins past a shorter replacement's end, the element is parked at its end and
 * stays silent.
 * @param {number} videoTime - Video-timeline position in seconds.
 * @returns {Promise<void>|null} A play() promise to fold into the caller's
 *   Promise.all, or null when there's nothing to play.
 */
function oaReplacePlayFrom(videoTime) {
  if (!oaReplaceAudio) return null;
  const dur = oaReplaceAudio.duration || 0;
  if (dur > 0 && videoTime >= dur) {
    oaReplaceAudio.currentTime = dur;
    if (!oaReplaceAudio.paused) oaReplaceAudio.pause();
    return null;
  }
  oaReplaceAudio.currentTime = Math.max(0, videoTime);
  _anchorAudio(oaReplaceAudio);
  return oaReplaceAudio.play();
}

/**
 * Realign the replacement to a new clock position (scrub / restart / loop). Resumes
 * when the video is playing and within the OA extent; parks silent past the OA end.
 * @param {number} videoTime - Video-timeline position in seconds.
 * @returns {void}
 */
function oaReplaceSeek(videoTime) {
  if (!oaReplaceAudio) return;
  const dur = oaReplaceAudio.duration || 0;
  if (dur > 0 && videoTime >= dur) {
    oaReplaceAudio.currentTime = dur;
    if (!oaReplaceAudio.paused) oaReplaceAudio.pause();
    return;
  }
  oaReplaceAudio.currentTime = Math.max(0, videoTime);
  _anchorAudio(oaReplaceAudio);
  if (!videoEl.paused && oaReplaceAudio.paused) oaReplaceAudio.play().catch(() => {});
}

/** Supported video containers (matches the product spec: MP4 primary, MOV secondary). */
const VIDEO_EXTS = ['.mp4', '.mov'];

/**
 * Whether a filename has a supported video-container extension.
 * @param {string} name - Filename to test.
 * @returns {boolean}
 */
function isVideoName(name) {
  return VIDEO_EXTS.some(ext => (name || '').toLowerCase().endsWith(ext));
}

/**
 * MARK: IMPORT DESCRIPTORS — both importers consume a { path, url, name } descriptor
 * instead of reaching into a File. Fresh import (drag/drop) builds one from a File
 * (blob: URL + native path); project restore builds one from a saved path
 * (ezl-file:// URL streamed off disk). Everything downstream — audio src, card
 * creation, export — only ever sees the URL + path.
 */

/**
 * Build an import descriptor from a dropped/picked File.
 * @param {File} file - Source file.
 * @returns {{path: (string|null), url: string, name: string}} Descriptor.
 */
function descriptorFromFile(file) {
  return { path: window.ezl?.getFilePath(file) ?? null, url: URL.createObjectURL(file), name: file.name };
}

/**
 * Build an import descriptor from a saved absolute path (project restore). Each path
 * segment is encoded so spaces / # / ? in filenames survive the URL round-trip; the
 * separators stay literal so the main-process handler resolves the same path back.
 * The explicit localhost host matters: ezl-file is a `standard` scheme, and an empty
 * host (triple-slash) doesn't canonicalize an absolute path cleanly — the host keeps
 * pathname reliable (= the absolute path). The handler ignores the host. e.g.
 * /Users/me/My Clip.mov → ezl-file://localhost/Users/me/My%20Clip.mov
 * @param {string} path - Absolute filesystem path.
 * @param {string} [name] - Display name; defaults to the path's basename.
 * @returns {{path: string, url: string, name: string}} Descriptor.
 */
function descriptorFromPath(path, name) {
  const url = 'ezl-file://localhost' + String(path).split('/').map(encodeURIComponent).join('/');
  return { path, url, name: name ?? String(path).split(/[\\/]/).pop() };
}

/**
 * Drag/drop + file-dialog entry point for video: validate by MIME, then hand off as
 * a descriptor.
 * @param {File|undefined} file - Dropped/picked file.
 * @returns {void}
 */
function loadVideoFile(file) {
  if (!file || !file.type.startsWith('video/')) {
    showError('Unsupported file — try MP4 or MOV');
    return;
  }
  loadVideoDescriptor(descriptorFromFile(file));
}

/**
 * Load a video from an import descriptor: swap out any prior video (dropping its
 * proxy transcode and OA replacement, and un-muting the element), reset the OA gain
 * to unity until analysis returns (silent if OA is toggled off), point both the main
 * player and the hidden frame-preview decoder at the URL, seed the element's volume
 * from the Master Volume slider (its handler only fires on 'input', so the displayed
 * level — incl. the 75% default — applies without a slider nudge), and kick off the
 * Python loudness analysis. The OA panel shows as soon as a video loads — even a
 * silent cut — so the Replace affordance is reachable; the waveform stays a
 * placeholder until embedded analysis or a replacement fills it. When analysis
 * returns it applies the same peak-normalisation gain used in export (-0.1 dBTP
 * ceiling) and lights AUTO-MIX if tracks are loaded.
 * @param {{path: (string|null), url: string, name: string}} desc - Import descriptor.
 * @returns {void}
 */
function loadVideoDescriptor(desc) {
  if (!desc || !isVideoName(desc.name)) {
    showError('Unsupported file — try MP4 or MOV');
    return;
  }
  if (videoLoaded) {
    videoEl.pause();
    URL.revokeObjectURL(_currentVideoBlobUrl); // no-op for ezl-file:// URLs
  }
  resetVideoProxyState();
  resetOaReplaceState();
  setupOaAudioGraph();
  oaNormGain = 1.0;
  oaGainNode.gain.value = oaEnabled ? 1.0 : 0.0;

  const url = desc.url;
  _currentVideoBlobUrl = url;
  vofxPeaks = null;
  document.querySelector('.oa-wave-wrap').classList.add('visible');
  videoEl.src = url;
  videoEl.volume = parseInt(document.getElementById('masterVol').value) / 100;
  scrubVideo.src = url; // hidden decode source for the trim-handle frame preview
  videoEl.style.display = 'block';
  videoDrop.classList.add('hidden');
  videoRemove.style.display = 'block';
  videoFilename.style.display = 'block';
  videoFilename.textContent = desc.name;
  scrub.classList.remove('disabled');
  btnPlay.disabled = false;
  btnRestart.disabled = false;
  btnLoop.disabled = false;
  videoLoaded = true;
  statusText.textContent = desc.name;
  updatePreviewBtn();
  syncRestartBtn();
  const videoPath = desc.path;
  currentVideoPath = videoPath; // stored for export
  if (videoPath && window.ezl) {
    window.ezl.analyzeLoudness(videoPath)
      .then(r => {
        if (r.status === 'ok') {
          console.log(`[OA] ${desc.name}: ${r.lufs} LUFS  peak ${r.peak_dbfs} dBFS  ${r.duration_sec}s`);
          oaLufs     = r.lufs;
          oaPeakDbfs = r.peak_dbfs;
          oaNormGain = Math.pow(10, (-0.1 - r.peak_dbfs) / 20);
          if (oaEnabled) oaGainNode.gain.value = oaNormGain;
          updateMusicLevelVisibility();
          if (trackMap.size > 0) markAutomixReady();
          showVofxPeaks(r.peaks, videoPath);
        } else {
          console.warn(`[OA] ${desc.name}: analysis ${r.status} — ${r.reason ?? r.error ?? ''}`);
        }
      })
      .catch(err => console.warn('[OA analysis]', err));
  }
  // Show placeholder immediately; the analysis above replaces it with real peaks
  drawWavePlaceholder(document.getElementById('vofxWave'), 101, '#1A2E48');
  pauseAll();
  updateHeader(); // re-evaluate Save Laybacks now that video is loaded
}

/**
 * Unload the current video and return the panel to its drop-zone state. Stales any
 * in-flight proxy transcode (freeing the proxy file), drops any OA replacement
 * (revoking its URL and un-muting the element), clears the video trim window and the
 * Music Start marker so the next clip starts fresh — the marker's DOM position is
 * zeroed too, so the next clip can't flash the old marker before its loadedmetadata
 * lands (both markers are hidden meanwhile by .scrub.disabled and .oa-wave-wrap
 * losing .visible) — and hides the marker readout, which also clears the filename's
 * visibility override so a stuck readout (e.g. video removed mid-drag) can't hide
 * the next clip's filename. Resets OA measurements, gains, buttons, and the header.
 * @returns {void}
 */
function unloadVideo() {
  videoEl.pause();
  resetVideoProxyState();
  resetOaReplaceState();
  URL.revokeObjectURL(_currentVideoBlobUrl);
  _currentVideoBlobUrl = null;
  vofxPeaks = null;
  document.querySelector('.oa-wave-wrap').classList.remove('visible');
  hideFramePreview();
  scrubVideo.src = '';
  videoEl.src = '';
  videoEl.style.display = 'none';
  videoDrop.classList.remove('hidden');
  videoRemove.style.display = 'none';
  videoFilename.style.display = 'none';
  hideMarkerReadout();
  scrub.classList.add('disabled');
  scrubFill.style.width = '0';
  scrubHead.style.left = '0';
  scrubTime.innerHTML = '0:00 &nbsp;/&nbsp; 0:00';
  videoTrimStart = 0;
  videoTrimEnd   = null;
  musicStartTime = null;
  scrubMusicMarker.style.left = '0';
  oaMusicMarker.style.left    = '0';
  layoutTrim(videoTrimEls(), 0, 1);
  layoutTrim(oaTrimEls(), 0, 1);
  btnPlay.disabled = true;
  btnPlay.innerHTML = '<span>&#x25B6;</span>';
  btnRestart.disabled = true;
  btnLoop.disabled = true;
  videoLoaded = false;
  currentVideoPath = null;
  oaLufs = null;
  oaPeakDbfs = null;
  oaNormGain = 1.0;
  if (oaGainNode) oaGainNode.gain.value = 0.0;
  updateMusicLevelVisibility();
  statusText.textContent = 'Ready';
  updatePreviewBtn();
  syncRestartBtn();
  clearAutomixReady();
  drawWavePlaceholder(document.getElementById('vofxWave'), 101, '#1A2E48');
  updateHeader(); // re-evaluate Save Laybacks now that video is gone
}

/**
 * MARK: VIDEO PREVIEW PROXY — Chromium has no decoder for pro picture codecs
 * (ProRes, DNxHD, …): such a file "loads", but its picture track is dropped — the
 * preview stays black while the audio plays. Detection is empirical rather than a
 * codec allowlist: metadata arrived with no picture (videoWidth 0), or the element
 * hard-errored (zero playable tracks). That exactly tracks what THIS Chromium build
 * can decode. Affected videos get a preview-only H.264/AAC proxy from the Python
 * worker (streamed over ezl-file://); export never sees it — Save Laybacks reads the
 * original file.
 */

const videoProxyOverlay = document.getElementById('videoProxyOverlay');
const videoProxySpinner = document.getElementById('videoProxySpinner');
const videoProxyText    = document.getElementById('videoProxyText');
const VIDEO_NO_PREVIEW_MSG =
  "This video can't be previewed here — Save Laybacks still delivers the full-quality picture.";

/**
 * Show the proxy overlay with a message and optional busy spinner.
 * @param {string} text - Overlay message.
 * @param {boolean} busy - Whether to show the spinner.
 * @returns {void}
 */
function showVideoProxyOverlay(text, busy) {
  videoProxyText.textContent = text;
  videoProxySpinner.hidden = !busy;
  videoProxyOverlay.hidden = false;
}

/**
 * Reset proxy state. Called on every video load/unload: hides the overlay,
 * invalidates any transcode still in flight (its result is for a video that's no
 * longer on screen), and lets the main process delete the previous proxy file (a
 * no-op when no proxy exists).
 * @returns {void}
 */
function resetVideoProxyState() {
  _videoLoadToken++;
  _usingVideoProxy = false;
  _videoProxyRequested = false;
  _videoProxySwapPending = false;
  videoProxyOverlay.hidden = true;
  window.ezl?.releaseVideoPreviewProxy?.();
}

/**
 * Request a preview proxy for the current video and swap the player onto it. With no
 * native path (blob-only import) or no bridge, transcoding is impossible, so the
 * overlay just explains. The swap stops playback first (an src swap mid-play would
 * desync the preview clock), keeps the user's trim window (_videoProxySwapPending —
 * same clip, new src), points the frame-preview decoder at the proxy too, and parks
 * the playhead where the user left it. The overlay stays up until the proxy actually
 * has a renderable picture — hiding at src-swap would show a black beat while the
 * proxy's metadata loads; that once-listener is token-guarded so if the user swaps
 * videos first it must not touch the new video's overlay (which may be a fresh
 * "Preparing…" of its own). If the video was swapped/removed mid-transcode, the
 * stale proxy is released here rather than leaving the temp file until the next
 * load/quit — main has already registered it as the live one. That release is safe
 * against the replacement's own transcode: the worker is FIFO, so a newer proxy can
 * only resolve — and re-register — after this call.
 * @returns {Promise<void>}
 */
async function beginVideoProxy() {
  _videoProxyRequested = true;
  if (!currentVideoPath || !window.ezl?.getVideoPreviewProxy) {
    showVideoProxyOverlay(VIDEO_NO_PREVIEW_MSG, false);
    return;
  }
  const token = _videoLoadToken;
  showVideoProxyOverlay('Preparing video preview…', true);
  try {
    const proxyPath = await window.ezl.getVideoPreviewProxy(currentVideoPath);
    if (token !== _videoLoadToken) {
      window.ezl?.releaseVideoPreviewProxy?.();
      return;
    }
    pauseAll();
    const { url } = descriptorFromPath(proxyPath);
    const resumeAt = videoEl.currentTime || 0;
    URL.revokeObjectURL(_currentVideoBlobUrl);
    _currentVideoBlobUrl = url;
    _usingVideoProxy = true;
    _videoProxySwapPending = true;
    videoEl.src = url;
    scrubVideo.src = url;
    videoEl.currentTime = resumeAt;
    videoEl.addEventListener('loadedmetadata', () => {
      if (token === _videoLoadToken) videoProxyOverlay.hidden = true;
    }, { once: true });
  } catch (err) {
    if (token !== _videoLoadToken) return;
    console.warn('[video preview proxy]', err);
    showVideoProxyOverlay(VIDEO_NO_PREVIEW_MSG, false);
  }
}

/*
 * Proxy detection — one transcode per video (_videoProxyRequested:
 * loadedmetadata-with-no-picture and a subsequent error can both fire for the same
 * file). The 'error' case covers files with zero Chromium-playable tracks, where
 * loadedmetadata never fires at all. A proxy that itself errors in the element can't
 * be fixed by another transcode — swap the spinner for the terminal no-preview
 * message instead of looping back into beginVideoProxy.
 */
videoEl.addEventListener('loadedmetadata', () => {
  if (videoLoaded && !_videoProxyRequested && videoEl.videoWidth === 0) beginVideoProxy();
});
videoEl.addEventListener('error', () => {
  if (!videoLoaded) return;
  if (_usingVideoProxy) { showVideoProxyOverlay(VIDEO_NO_PREVIEW_MSG, false); return; }
  if (!_videoProxyRequested) beginVideoProxy();
});

document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop',     e => e.preventDefault());

videoDrop.addEventListener('click', () => videoFileInput.click());
videoFileInput.addEventListener('change', e => {
  if (e.target.files[0]) loadVideoFile(e.target.files[0]);
  e.target.value = '';
});
videoDrop.addEventListener('dragover',  e => { e.preventDefault(); videoDrop.classList.add('drag-over'); });
videoDrop.addEventListener('dragleave', e => { if (!videoDrop.contains(e.relatedTarget)) videoDrop.classList.remove('drag-over'); });
videoDrop.addEventListener('drop', e => {
  e.preventDefault();
  e.stopPropagation();
  videoDrop.classList.remove('drag-over');
  loadVideoFile(e.dataTransfer.files[0]);
});
videoRemove.addEventListener('click', unloadVideo);

// OA replacement: the header button opens a picker; the OA waveform doubles as a drop target
document.getElementById('oaReplaceBtn').addEventListener('click', () => document.getElementById('oaFileInput').click());
document.getElementById('oaFileInput').addEventListener('change', e => {
  if (e.target.files[0]) loadOaReplacement(e.target.files[0]);
  e.target.value = ''; // allow re-selecting the same file
});
document.getElementById('oaReplaceClear').addEventListener('click', clearOaReplacement);
oaWrap.addEventListener('dragover',  e => { if (videoLoaded) { e.preventDefault(); oaWrap.classList.add('oa-drop-hover'); } });
oaWrap.addEventListener('dragleave', e => { if (!oaWrap.contains(e.relatedTarget)) oaWrap.classList.remove('oa-drop-hover'); });
oaWrap.addEventListener('drop', e => {
  e.preventDefault();
  e.stopPropagation();
  oaWrap.classList.remove('oa-drop-hover');
  if (e.dataTransfer.files[0]) loadOaReplacement(e.dataTransfer.files[0]);
});

// Play toggles between stop and start (preview when a track is active, bare video
// playback otherwise).
btnPlay.addEventListener('click', () => {
  if (!videoLoaded) return;
  if (isPreviewing || !videoEl.paused) stopPreview();
  else if (activeTrackId !== null) startPreview();
  else playVideoBounded();
});
/*
 * Restart seeks to the trim In point (0 when untrimmed). Playback state is captured
 * before the seek to decide whether to resume; when paused, the scrub bar and
 * waveform playheads are moved immediately (the rAF loop only runs while playing).
 */
btnRestart.addEventListener('click', () => {
  if (!videoLoaded) return;
  const wasPlaying = isPreviewing || !videoEl.paused;
  videoEl.currentTime = vIn();
  if (wasPlaying) {
    if (activeTrackId !== null) startPreview();
    else playVideoBounded();
  } else {
    homePausedPlayheadToIn();
  }
});

/**
 * LOOP toggle — when active, playback wraps at the trim-Out / end point back to the
 * In point instead of stopping. Default ON (matches the .active class in the markup).
 * @type {boolean}
 */
let loopEnabled = true;
btnLoop.addEventListener('click', () => {
  loopEnabled = !loopEnabled;
  btnLoop.classList.toggle('active', loopEnabled);
});

/*
 * Native end-of-media (untrimmed full play, when the rAF guard didn't catch the last
 * frame before the browser paused at duration). Single source of truth for
 * end-of-clip behavior. Looping restarts from the In point via the proven restart
 * path: a bare videoEl.play() would resume with a dead rAF loop (it self-terminated
 * when the video paused at end), so it goes through startPreview() /
 * playVideoBounded(), which re-arm the animation loop, re-anchor the clock, re-seek
 * the active track, and reschedule the tail fade. Not looping stops cleanly.
 */
videoEl.addEventListener('ended', () => {
  if (loopEnabled && videoLoaded) {
    videoEl.currentTime = vIn();
    if (activeTrackId !== null) startPreview();
    else playVideoBounded();
    return;
  }
  if (isPreviewing) stopPreview();
  else { btnPlay.innerHTML = '<span>&#x25B6;</span>'; resetVofxWaveform(); }
});

/*
 * Reset the video trim window to full extent each time a new clip's duration is
 * known, then lay out the handles (the handle DOM is permanent, so the drag is wired
 * only once). Exception — a proxy swap: same clip, new src. The user may have
 * trimmed or placed the Music Start marker while the proxy rendered, so both are
 * kept instead of reset (durations match to within a frame). Otherwise the marker
 * goes back to "not placed" (following the new clip's In point) and any
 * already-loaded tracks are re-fitted to the new video's length.
 */
videoEl.addEventListener('loadedmetadata', () => {
  if (_videoProxySwapPending) { _videoProxySwapPending = false; return; }
  videoTrimStart = 0;
  videoTrimEnd   = null;
  musicStartTime = null;
  updateVideoTrimUI();
  syncAudioEndsToVideo();
});
/*
 * Video trim drag wiring — scrub bar handles. The Music Start marker rides the trim
 * window and can never sit outside it:
 * - In / Out drag → the marker holds its ABSOLUTE position, PUSHED only by an edge
 *   that runs into it.
 * - band slide → the marker keeps its OFFSET inside the window, so the music/picture
 *   relationship the user set up survives moving the whole selection.
 * In setStart, videoTrimStart still holds the PREVIOUS In — which is exactly the
 * delta rideWindow needs for the slide case. This lives at the call site, not inside
 * attachTrimDrag: that engine is shared with the per-track audio trims, which must
 * never touch the marker. In setEnd, a slide already moved the marker via setStart
 * (which always fires first) and preserves the window's width, so the Out edge
 * cannot push it — only an Out DRAG can squeeze it; rideWindow rather than
 * clampMusicStart so an unset (null) marker stays unset instead of hardening into a
 * number at the current In point. relayout relays out the video trim AND re-fits
 * every track's Out handle; onPreview shows the frame at the dragged point (popover
 * while playing, main seek while paused); a plain click on the band seeks, same as
 * clicking the scrub bar itself.
 */
attachTrimDrag({
  rectEl: scrub, handleIn: scrubTrimIn, handleOut: scrubTrimOut, tooltipEl: scrubTrimTip,
  handleRegion: scrubRegion,
  getDuration: () => videoEl.duration,
  getStart: () => videoTrimStart,
  getEnd:   () => videoTrimEnd,
  setStart: (s, mode) => {
    musicStartTime = Timeline.rideWindow({
      musicStart: musicStartTime,
      prevIn:  videoTrimStart || 0,
      nextIn:  s,
      nextOut: videoTrimEnd ?? videoEl.duration,
      slid:    mode === 'slide',
      gap:     trimGap(),
    });
    videoTrimStart = s;
  },
  setEnd:   (e, mode) => {
    videoTrimEnd = e;
    if (mode !== 'slide') {
      const inPt = videoTrimStart || 0;
      musicStartTime = Timeline.rideWindow({
        musicStart: musicStartTime,
        prevIn: inPt, nextIn: inPt,          // In didn't move
        nextOut: videoTrimEnd ?? videoEl.duration,
        slid: false, gap: trimGap(),
      });
    }
  },
  relayout: () => { updateVideoTrimUI(); syncAudioEndsToVideo(); },
  onPreview: sec => previewFrameAt(sec),
  onRegionClick: cx => {
    if (!videoLoaded || !videoEl.duration) return;
    const r = scrub.getBoundingClientRect();
    doSeek(clamp01((cx - r.left) / r.width) * videoEl.duration);
  },
});
/*
 * OA waveform handles — wired to the SAME video-trim state and relayout as the scrub
 * handles (including identical Music Start marker riding — see the block above), so
 * the two sets are always locked together no matter which one the user drags.
 */
attachTrimDrag({
  rectEl: oaWrap, handleIn: oaTrimIn, handleOut: oaTrimOut, tooltipEl: oaTrimTip,
  handleRegion: oaRegion,
  getDuration: () => videoEl.duration,
  getStart: () => videoTrimStart,
  getEnd:   () => videoTrimEnd,
  setStart: (s, mode) => {
    musicStartTime = Timeline.rideWindow({
      musicStart: musicStartTime,
      prevIn:  videoTrimStart || 0,
      nextIn:  s,
      nextOut: videoTrimEnd ?? videoEl.duration,
      slid:    mode === 'slide',
      gap:     trimGap(),
    });
    videoTrimStart = s;
  },
  setEnd:   (e, mode) => {
    videoTrimEnd = e;
    if (mode !== 'slide') {
      const inPt = videoTrimStart || 0;
      musicStartTime = Timeline.rideWindow({
        musicStart: musicStartTime,
        prevIn: inPt, nextIn: inPt,          // In didn't move
        nextOut: videoTrimEnd ?? videoEl.duration,
        slid: false, gap: trimGap(),
      });
    }
  },
  relayout: () => { updateVideoTrimUI(); syncAudioEndsToVideo(); },
  onPreview: sec => previewFrameAt(sec),
  onRegionClick: cx => {
    if (!videoLoaded || !videoEl.duration) return;
    const r = oaWrap.getBoundingClientRect();
    doSeek(clamp01((cx - r.left) / r.width) * videoEl.duration);
  },
});
/*
 * Music Start markers. Both are wired to the SAME state and the same single writer,
 * so they stay locked together no matter which one the user drags — like the trim
 * handles above.
 */
attachMarkerDrag({ rectEl: scrub,  marker: scrubMusicMarker });
attachMarkerDrag({ rectEl: oaWrap, marker: oaMusicMarker });

/**
 * Whether an element is a true text-entry field. Spacebar is reserved for playback
 * start/stop (mirroring btnPlay click), and only true text-entry fields keep it so a
 * space can be typed. Range sliders, buttons, checkboxes, etc. must NOT swallow it,
 * so Space keeps toggling playback even while a button or slider has focus after a
 * click.
 * @param {Element|null} el - Element to test (usually document.activeElement).
 * @returns {boolean} True when the element accepts typed text.
 */
function isTextEntry(el) {
  if (!el) return false;
  if (el.tagName === 'TEXTAREA' || el.isContentEditable) return true;
  if (el.tagName === 'INPUT') {
    const t = (el.type || 'text').toLowerCase();
    return !['range', 'checkbox', 'radio', 'button', 'submit', 'reset', 'color', 'file'].includes(t);
  }
  return false;
}
/*
 * Spacebar toggles playback. Auto-repeat is ignored and text fields keep the key.
 * A non-playback dialog (settings/help/account/error/missing) being open leaves
 * Space to the focused control — playback isn't toggled behind the modal. The Save
 * modal is the one exception: playback runs under it, so Space must still stop it
 * there (mirrors the Enter handler's dialog guard). preventDefault stops the
 * page-scroll AND the native activation of a focused button/slider — this is what
 * keeps Space toggling playback instead of "re-clicking" whatever button was last
 * clicked.
 */
document.addEventListener('keydown', e => {
  if (e.code !== 'Space') return;
  if (e.repeat) return;
  if (isTextEntry(document.activeElement)) return;
  if ([...document.querySelectorAll('dialog[open]')].some(d => d.id !== 'saveModal')) return;
  e.preventDefault();
  if (!videoLoaded) return;
  if (isPreviewing || !videoEl.paused) stopPreview();
  else if (activeTrackId !== null) startPreview();
  else playVideoBounded();
});

/*
 * Enter is the "primary action" key.
 * - Save modal open → presses whichever primary button is currently showing
 *   (Done/Close after a run, otherwise GO). This works no matter which button the
 *   dialog auto-focused, so Enter never accidentally closes via the × or re-opens
 *   the folder picker.
 * - Settings modal open → presses Done (save + close), same as the Save modal.
 * - Main screen (nothing focused) → presses "Save Laybacks".
 * Enter is deliberately left alone when a control is focused (so it still activates
 * that button/link the standard way) and while typing in a field.
 */
document.addEventListener('keydown', e => {
  if (e.key !== 'Enter' || e.repeat) return;
  if (isTextEntry(document.activeElement)) return;

  const saveModal = document.getElementById('saveModal');
  if (saveModal?.open) {
    // Prefer Done/Close (completion state); otherwise GO if it's visible + enabled.
    const btnDone = document.getElementById('btnSaveModalDone');
    const btnGo   = document.getElementById('btnSaveModalGo');
    const primary = !btnDone.classList.contains('hidden') ? btnDone
                  : (!btnGo.classList.contains('hidden') && !btnGo.disabled) ? btnGo
                  : null;
    if (primary) { e.preventDefault(); primary.click(); }
    return;
  }

  const settingsModal = document.getElementById('settingsModal');
  if (settingsModal?.open) {
    e.preventDefault();
    document.getElementById('btnSettingsDone').click();
    return;
  }

  // Any other dialog open (help/account/error/missing) — leave Enter alone.
  if (document.querySelector('dialog[open]')) return;

  // Main screen: Enter is reserved for Save Laybacks — always preventDefault so a
  // focused button isn't also activated (mirrors Space being reserved for playback).
  e.preventDefault();
  const btnSave = document.getElementById('btnSave');
  if (btnSave && !btnSave.disabled) btnSave.click(); // no-op (but still reserved) when disabled
});

/*
 * Restart (R), Loop toggle (L) and Music Start (M) — playback shortcuts, mirroring
 * the Space/Enter guards above; main-screen only (no modal open), and OS/browser
 * combos aren't hijacked. Disabled buttons ignore .click(), so R/L need no
 * videoLoaded check; M writes state directly, so it checks explicitly. M drops the
 * Music Start marker at the playhead — works paused or mid-preview. Previewing, this
 * reads as a live "start the music here": resyncAfterTrimRelease lands the track at
 * tt ≈ aIn, which is NOT below it, so the music comes in instantly at the playhead.
 * The marker position comes from getVideoTime() — the interpolated clock the rAF
 * loop draws from, which already falls back to videoEl.currentTime when paused, so
 * one call covers both cases; reading currentTime directly while playing would drop
 * the marker up to a frame BEHIND where the playhead was drawn when the key went
 * down.
 */
document.addEventListener('keydown', e => {
  if (e.repeat || e.metaKey || e.ctrlKey || e.altKey) return;
  if (isTextEntry(document.activeElement)) return;
  if (document.querySelector('dialog[open]')) return;
  const key = e.key.toLowerCase();
  if (key === 'r') { e.preventDefault(); btnRestart.click(); }
  else if (key === 'l') { e.preventDefault(); btnLoop.click(); }
  else if (key === 'm') {
    e.preventDefault();
    if (!videoLoaded || !videoEl.duration) return;
    setMusicStart(getVideoTime()); // clamps into [vIn, vOut − gap]; re-fits every track's Out
    resyncAfterTrimRelease();      // re-gate the active track against the new marker
    showToast(`Music Start · ${fmt(mStart())}`);
  }
});

videoEl.addEventListener('timeupdate', () => {
  _anchorVideo(); // refresh interpolation anchor on every discrete clock tick
  if (!videoEl.duration) return;
  scrubTime.innerHTML = `${fmt(videoEl.currentTime)} &nbsp;/&nbsp; ${fmt(videoEl.duration)}`;
  syncRestartBtn();
});

/**
 * Re-align the active (previewing) track to a given video time using the current
 * trim offset (trackTimeFor folds in both the video In point and the track's own
 * audio In point). Pulled out of doSeek so a trim-handle release can reuse it
 * WITHOUT re-seeking the video. Three cases:
 * - BEFORE the Music Start marker — the music hasn't come in yet. Park it silent and
 *   PRE-SEEKED at its In point, and reset the waveform: no progress has been made.
 *   Pre-seeking is the point: crossing the marker then costs a bare play() instead
 *   of a ~50ms audio-element seek, which is audible as a late entry against picture.
 *   Without this branch the aligned-seek case below would seek BELOW the In point
 *   and play the head the user trimmed off — on every seek-while-previewing and
 *   every loop restart. The epsilon absorbs the playhead sitting exactly ON the
 *   marker (e.g. right after pressing M mid-preview); that must count as "in", or
 *   the music stutters for a frame. The tail fade is re-armed from here: the video
 *   runs continuously to vOut from this point, so the ramp still lands on silence at
 *   the last frame even though the track is parked.
 * - At/past the track's Out point (trim end, or duration when untrimmed) — park
 *   silent and fully shade the waveform so the user sees the track has reached its
 *   end; no tail fade needed past the end.
 * - Otherwise — seek the track to its offset position so it stays aligned with the
 *   video, re-anchor its clock, resume it if it had ended (offset moved backward
 *   past its end), and refresh the tail fade from the new position.
 * @param {number} videoTime - Video-timeline position in seconds.
 * @returns {void}
 */
function syncActiveTrackToVideo(videoTime) {
  const active = trackMap.get(activeTrackId);
  if (!active?.audio) return;
  const trackDur = active.audio.duration || 0;
  const tt = trackTimeFor(active, videoTime);

  if (tt < aIn(active) - 0.001) {
    active.audio.currentTime = aIn(active);
    if (!active.audio.paused) active.audio.pause();
    cancelTrackFade(active, activeTrackId);
    resetTrackWaveform(activeTrackId);
    scheduleTrackFade(active, videoTime, vOut());
    return;
  }

  if (trackDur > 0 && tt >= aOut(active)) {
    active.audio.currentTime = Math.min(tt, trackDur);
    if (!active.audio.paused) active.audio.pause();
    cancelTrackFade(active, activeTrackId);
    drawTrackShade(activeTrackId, 1);
  } else {
    active.audio.currentTime = Math.max(0, tt);
    _anchorAudio(active.audio);
    if (active.audio.paused) active.audio.play().catch(() => {});
    cancelTrackFade(active, activeTrackId);
    scheduleTrackFade(active, videoTime, vOut());
  }
}

/**
 * Seek the video to a time, sync the active track if previewing, and refresh the
 * restart button. Called by the scrub bar, OA waveform, and track waveform click
 * handlers. A non-finite target — NaN from a 0/0 fraction on a zero-width bar, or
 * +/-Infinity from an Infinity-duration video — throws when assigned to currentTime
 * (or is silently dropped), leaving the playhead and active track desynced; it is
 * never a meaningful seek, so it bails. While previewing the seek is kept inside the
 * video trim window; when paused the user is free to scrub anywhere (e.g. to drag a
 * trim handle's reference point). The clock is re-anchored so the animation stays
 * smooth right after the seek, and the replacement OA is kept aligned (paused or
 * playing; no-op if none). Paused, there is no rAF loop, so the playhead UI is
 * updated directly for immediate feedback — scrub bar + OA waveform, plus the active
 * track's shading (live playhead / full-shade).
 * @param {number} t - Target video time in seconds.
 * @returns {void}
 */
function doSeek(t) {
  if (!isFinite(t)) return;
  if (isPreviewing) t = Math.max(vIn(), Math.min(t, vOut()));
  videoEl.currentTime = t;
  _anchorVideo();
  oaReplaceSeek(t);

  if (isPreviewing) {
    syncActiveTrackToVideo(t);
  } else {
    drawStaticVideoPlayhead(t);
    if (activeTrackId !== null) refreshTrackShade(activeTrackId, t);
  }

  syncRestartBtn();
}

/**
 * Re-home the playhead to the In point while paused, and refresh the static
 * playheads directly (the rAF loop only runs during playback). Re-homes the
 * replacement OA too, and parks the active track via parkTrackAt (its In point when
 * the marker is later). Mirrors the Restart button's paused path.
 * @returns {void}
 */
function homePausedPlayheadToIn() {
  videoEl.currentTime = vIn();
  _anchorVideo();
  oaReplaceSeek(vIn());
  const pct = videoEl.duration ? vIn() / videoEl.duration : 0;
  scrubFill.style.width = (pct * 100) + '%';
  scrubHead.style.left  = (pct * 100) + '%';
  resetVofxWaveform();
  if (activeTrackId !== null) {
    const t = trackMap.get(activeTrackId);
    if (t) {
      t.audio.currentTime = parkTrackAt(t, vIn());
      resetTrackWaveform(activeTrackId);
    }
  }
  syncRestartBtn();
}

/**
 * Settle playback after a trim handle/band release: if the playhead fell outside the
 * (resized) window, snap it to the In point — both "before In" and "after Out" go to
 * the start. Playing: doSeek moves the video AND re-syncs the track; inside the
 * window just re-align the track without disturbing the still-playing video. Paused:
 * re-home if the playhead now sits outside the window — the tolerance at Out absorbs
 * the seek frame-snap that left it a hair inside, which is what made the next Play
 * (whose re-home test is exact) need a couple of tries before jumping to the start.
 * @returns {void}
 */
function resyncAfterTrimRelease() {
  if (isPreviewing) {
    const vt = getVideoTime();
    if (vt < vIn() || vt > vOut()) doSeek(vIn());
    else                           syncActiveTrackToVideo(vt);
    return;
  }
  const ct = videoEl.currentTime;
  if (ct < vIn() || ct >= vOut() - 0.05) homePausedPlayheadToIn();
}

/*
 * Press anywhere on the bar (not on a handle/region, which own their drags) starts a
 * live scrub: seek on press, then follow the cursor. doSeek() re-anchors the clock
 * and syncs the active track, so this works paused (frame updates in the main
 * player) or playing (position scrubs live). A plain click is just a zero-distance
 * drag, so click-to-seek still works.
 */
scrub.addEventListener('mousedown', e => {
  if (!videoLoaded || !videoEl.duration) return;
  if (_trimDragging
      || e.target.classList.contains('trim-handle')
      || e.target.classList.contains('trim-region')) return;
  const rect = scrub.getBoundingClientRect();
  const seekFromX = x => doSeek(clamp01((x - rect.left) / rect.width) * videoEl.duration);
  seekFromX(e.clientX);              // jump immediately on press
  const move = ev => seekFromX(ev.clientX);
  const up = () => {
    document.removeEventListener('mousemove', move);
    document.removeEventListener('mouseup', up);
  };
  document.addEventListener('mousemove', move);
  document.addEventListener('mouseup', up);
});

// OA waveform click — same seek semantics as the scrub bar; ignored while grabbing
// a trim handle.
_vofxWaveCanvas.addEventListener('click', e => {
  if (!videoLoaded || !videoEl.duration) return;
  if (_trimDragging || e.target.classList.contains('trim-handle')) return;
  const rect = _vofxWaveCanvas.getBoundingClientRect();
  doSeek(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) * videoEl.duration);
});

/** MARK: TRACKS — track list state, import, cards, and per-track wiring. */

const tracksList  = document.getElementById('tracksList');
const tracksEmpty = document.getElementById('tracksEmpty');
const tracksCt    = document.getElementById('tracksCt');
const trackDrop   = document.getElementById('trackDrop');
const trackFileInput = document.getElementById('trackFileInput');
const panelRight  = document.getElementById('panelRight');

const trackMap = new Map(); // id → { audio, blobUrl, seed, peaks, filePath, lufs }
let trackIdCounter = 0;
let activeTrackId  = null;
let isPreviewing   = false;
let oaEnabled      = true;
let oaLufs         = null; // integrated LUFS of the video's audio stream
let oaPeakDbfs     = null; // sample peak of the video's audio stream
let oaGainNode     = null; // Web Audio GainNode that normalises the OA signal in preview
let oaNormGain     = 1.0;  // current normalised linear gain applied to oaGainNode
/*
 * Original Audio REPLACEMENT state: a separate audio file standing in for the
 * video's embedded stream. When one is loaded, the embedded stream is muted and
 * this element plays through oaGainNode instead — so the ON/OFF toggle and
 * -0.1 dBTP normalisation apply unchanged. It maps 1:1 to the video clock (starts
 * at :00) and is cropped by the video end on export.
 */
let oaReplacePath  = null; // path of the replacement file (null = use the video's embedded OA)
let oaReplaceName  = null; // filename, for the header chip
let oaReplaceUrl   = null; // object URL attached to the <audio> element (revoked on clear)
let oaReplaceAudio = null; // hidden <audio> element for the replacement (created per load)
let oaReplaceSrc   = null; // its MediaElementAudioSourceNode → oaGainNode
let oaReplaceToken = 0;    // bumps per load; stales in-flight analysis/transcode on rapid re-drops

// Save Laybacks modal state.
let lastOutputDir    = null;  // remembers the folder used in this session to pre-fill on reopen
let modalDir         = null;  // live destination path while the modal is open
let nameAffixText    = '';    // Settings: text to add to layback filenames ('' = none)
let nameAffixPos     = 'after'; // Settings: 'before' | 'after' the track name
let exportCancelled    = false; // set by Cancel; checked between per-track exports
let exportInProgress   = false; // guard against GO handler re-firing during completion
let lastExportError    = null;  // friendly error message from the most recent failed track (shown)
let lastErrorDetail    = null;  // raw technical detail for that error (copied via the copy button)
let userSelectedFolder = false; // true only when the user explicitly picked a folder via "Change…"

/**
 * Extensions accepted as tracks. .mp4/.mov are accepted too — the worker extracts
 * their audio stream, so a video bounce from an editor can be auditioned like any
 * music file.
 */
const AUDIO_EXTS = ['.wav', '.aiff', '.aif', '.mp3', '.m4a', '.aac', '.mp4', '.mov'];

/**
 * Whether a file is importable as a track, by MIME type or extension.
 * @param {File} file - File to test.
 * @returns {boolean}
 */
function isAudioFile(file) {
  if (file.type.startsWith('audio/')) return true;
  const name = file.name.toLowerCase();
  return AUDIO_EXTS.some(ext => name.endsWith(ext));
}

/**
 * Strip the final extension from a filename.
 * @param {string} name - Filename.
 * @returns {string} Name without its extension.
 */
function stripExt(name) {
  return name.replace(/\.[^.]+$/, '');
}

/**
 * Escape a string for safe interpolation into HTML.
 * @param {string} str - Raw string.
 * @returns {string} Escaped string.
 */
function escHtml(str) {
  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

/**
 * Generate a free batch-output folder path: <parent>/EZL_<video basename>. Used only
 * when exporting more than one layback, so a batch lands in its own folder; a
 * single-track export skips this and writes straight into the parent. The extension
 * is stripped so it reads "EZL_myvideo", not "EZL_myvideo.mp4". If the directory
 * already exists, a new name is enumerated (EZL_myvideo (2), (3), …) until a free
 * one is found.
 * @param {string} baseDir - Parent directory.
 * @param {string} videoPath - Path of the loaded video, source of the basename.
 * @returns {Promise<string>} A non-existing directory path.
 */
async function genEzlFolder(baseDir, videoPath) {
  const base = videoPath.split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
  let path = `${baseDir}/EZL_${base}`;
  let n = 2;
  let exists = await window.ezl.pathExists(path);
  while (exists) {
    path = `${baseDir}/EZL_${base} (${n})`;
    exists = await window.ezl.pathExists(path);
    n++;
  }
  return path;
}

/**
 * Apply the auto-mix gain to one track. Gain math lives in automix.js (pure,
 * unit-tested); this function just feeds it the measured loudness/peak values and
 * writes the result back to the DOM + GainNode. OA mode: peak-normalise OA to
 * -0.1 dBTP, then offset down by Music Level. No-OA mode: find the quietest track
 * and peak-normalise it to -0.1 dBTP; all tracks target the same shared LUFS so the
 * mix stays balanced, and the Music Level offset is not applied (the slider is
 * hidden in this mode) — skipped entirely while no track has peak data yet. The
 * slider snaps to its 0.5 dB step grid, so the snapped value is read back so the
 * number, the thumb, and the GainNode all agree (and the next Music Level delta
 * reads a slider value that matches what was displayed). Auto-Mix is a step change,
 * not a drag — the gain is written hard, but through setTrackGain so a fade already
 * scheduled on this track is rebuilt at the new level instead of holding the old
 * one.
 * @param {number} id - Track id in trackMap.
 * @returns {void}
 */
function applyInitialGain(id) {
  const t = trackMap.get(id);
  if (!t || t.lufs === null) return;

  let targetLufs;
  if (oaEnabled && oaLufs !== null && oaPeakDbfs !== null) {
    const offset = parseInt(document.getElementById('musicLevel').value);
    targetLufs = AutoMix.targetLufsOA(oaLufs, oaPeakDbfs, offset);
  } else {
    let minLufs = Infinity, anchorPeak = null;
    trackMap.forEach(tr => {
      if (tr.lufs !== null && tr.peak_dbfs !== null && tr.lufs < minLufs) {
        minLufs    = tr.lufs;
        anchorPeak = tr.peak_dbfs;
      }
    });
    if (anchorPeak === null) return;
    targetLufs = AutoMix.targetLufsNoOA(minLufs, anchorPeak);
  }

  const gainDb = AutoMix.trackGainDb(targetLufs, t.lufs);
  const card   = document.querySelector(`.track-card[data-track-id="${id}"]`);
  if (!card) return;
  const slider  = card.querySelector('.s-trim');
  const trimVal = card.querySelector('.trim-val');
  slider.value  = gainDb;
  const applied = parseFloat(slider.value);
  trimVal.textContent = applied === 0 ? '0'
    : (applied > 0 ? '+' + applied.toFixed(1) : applied.toFixed(1));
  setTrackGain(t, id, AutoMix.dbToLinear(applied));
}

/**
 * Refresh the tracks header count and the AUTO-MIX / Save Laybacks button states.
 * No-OA mode (no video) needs 2+ tracks to level relative to each other; OA mode
 * needs 1+. Save needs a video + at least one track. Entitlement does NOT disable
 * the Save button — an expired account clicks it normally and gets the expired
 * modal from requireCanSave(), which explains itself better than a dead button.
 * @returns {void}
 */
function updateHeader() {
  const n = trackMap.size;
  tracksCt.textContent = n === 0 ? 'No tracks'
    : `${n} track${n !== 1 ? 's' : ''} — ${n} layback${n !== 1 ? 's' : ''} queued`;
  tracksEmpty.style.display = n === 0 ? '' : 'none';
  document.getElementById('btnAutomix').disabled = videoLoaded ? n < 1 : n < 2;
  const btnSave = document.getElementById('btnSave');
  btnSave.disabled = !(videoLoaded && n > 0);
}

/**
 * Make a track the active one, updating card highlighting and — during preview —
 * seamlessly swapping audio without stopping the video. Every card (active and
 * inactive) is redrawn with correct end-shading for the current playhead, so
 * switching tracks always shows the right state — full shade if that track is
 * at/past its end; the previewing branch then refines the newly-active track (live
 * playhead or park). On a preview swap the old track's gain is restored before it
 * idles, and the new track lands in one of three states mirroring
 * syncActiveTrackToVideo:
 * - Playhead BEFORE the Music Start marker — parked silent and pre-seeked at its In
 *   point. Without this branch it would fall into the aligned case (it isn't past
 *   aOut either), get Math.max(0, …)-clamped, and play the trimmed-off head.
 * - Already at/past its end for the current position — parked silent and fully
 *   shaded (not played).
 * - Otherwise — seeked to the video position and played. The seek races 'seeked'
 *   against a 1s timeout: an undecoded track may never fire it, which would wedge
 *   the swap with the preview button stuck showing "Stop" while nothing plays
 *   (video keeps running, audio silent). Bails if the user switched again while
 *   seeking; play() errors are swallowed (AbortError on rapid re-switch, matching
 *   the other play() calls), and a fade-out is scheduled if the track outlasts the
 *   video trim window.
 * @param {number} id - Track id to activate.
 * @returns {Promise<void>}
 */
async function setActiveTrack(id) {
  const previousId = activeTrackId;
  activeTrackId = id;

  document.querySelectorAll('.track-card').forEach(card => {
    const cid = parseInt(card.dataset.trackId);
    card.classList.toggle('active', cid === id);
    refreshTrackShade(cid, videoEl.currentTime);
  });

  if (isPreviewing && previousId !== id) {
    const oldT = trackMap.get(previousId);
    const newT = trackMap.get(id);
    if (oldT) {
      oldT.audio.pause();
      cancelTrackFade(oldT, previousId);
    }
    if (newT) {
      const trackDur = newT.audio.duration || 0;
      const target   = trackTimeFor(newT, videoEl.currentTime);
      const btn = getActivePreviewBtn();
      if (btn) { btn.innerHTML = '&#x25A0; Stop'; btn.classList.add('previewing'); }
      if (target < aIn(newT) - 0.001) {
        newT.audio.currentTime = aIn(newT);
        if (!newT.audio.paused) newT.audio.pause();
        cancelTrackFade(newT, id);
        resetTrackWaveform(id);
        scheduleTrackFade(newT, videoEl.currentTime, vOut());
      } else if (trackDur > 0 && target >= aOut(newT)) {
        newT.audio.currentTime = Math.min(target, trackDur);
        if (!newT.audio.paused) newT.audio.pause();
        cancelTrackFade(newT, id);
        drawTrackShade(id, 1);
      } else {
        const syncTime = Math.max(0, trackDur > 0 ? Math.min(target, trackDur) : target);
        await new Promise(resolve => {
          if (Math.abs(newT.audio.currentTime - syncTime) < 0.05) { resolve(); return; }
          let settled = false;
          const done = () => { if (settled) return; settled = true; newT.audio.removeEventListener('seeked', done); resolve(); };
          newT.audio.addEventListener('seeked', done, { once: true });
          newT.audio.currentTime = syncTime;
          setTimeout(done, 1000);
        });
        if (activeTrackId !== id) return;
        _anchorAudio(newT.audio);
        newT.audio.play().catch(() => {});
        scheduleTrackFade(newT, videoEl.currentTime, vOut());
      }
    }
  }

  updatePreviewBtn();
  syncRestartBtn();
}

/**
 * Remove a track: stop it (tearing down the whole preview if it was previewing),
 * revoke its blob URL and any transcoded-WAV preview URL, drop it from trackMap,
 * remove its card, and — if it was the active track — activate the first remaining
 * one.
 * @param {number} id - Track id to remove.
 * @returns {void}
 */
function removeTrack(id) {
  const t = trackMap.get(id);
  if (!t) return;
  if (isPreviewing && id === activeTrackId) pauseAll();
  t.audio.pause();
  URL.revokeObjectURL(t.blobUrl);
  if (t.previewUrl) URL.revokeObjectURL(t.previewUrl);
  trackMap.delete(id);
  if (activeTrackId === id) activeTrackId = null;
  const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
  const wasActive = card?.classList.contains('active');
  card?.remove();
  if (wasActive && trackMap.size > 0) setActiveTrack(trackMap.keys().next().value);
  updateHeader();
  updatePreviewBtn();
}

/**
 * Build a track card: markup, waveform canvas and trim overlays, Volume slider,
 * Preview/Restart/quick-save buttons, and all their wiring (click-to-seek, per-track
 * trim drag, Web Audio gain graph, analysis kickoff).
 * @param {number} id - Track id in trackMap (entry must already exist).
 * @param {string} name - Display name (extension already stripped).
 * @param {number} seed - Placeholder-waveform RNG seed.
 * @param {HTMLAudioElement} audio - The track's audio element.
 * @returns {HTMLElement} The card element, ready to append.
 */
function createTrackCard(id, name, seed, audio) {
  const card = document.createElement('div');
  card.className = 'track-card';
  card.dataset.trackId = id;
  card.dataset.seed = seed;
  card.innerHTML = `
    <div class="track-top">
      <div class="track-dot"></div>
      <span class="track-name">${escHtml(name)}</span>
      <div class="track-btns">
        <button class="tbtn tbtn-remove" title="Remove">&#x2715;</button>
      </div>
    </div>
    <div class="tw-wrap">
      <canvas class="tw" height="28"></canvas>
      <div class="trim-dim trim-dim-left  tw-dim-l"></div>
      <div class="trim-dim trim-dim-right tw-dim-r"></div>
      <div class="trim-region tw-region"></div>
      <div class="trim-handle trim-handle-in  tw-trim-in"></div>
      <div class="trim-handle trim-handle-out tw-trim-out"></div>
      <div class="trim-tooltip tw-trim-tip"></div>
    </div>
    <div class="trim-row">
      <span class="trim-label">Volume</span>
      <input type="range" class="s-trim" min="-24" max="6" value="0" step="0.5">
      <span class="trim-val">0</span>
    </div>
    <div class="track-actions">
      <button class="btn-track-preview">
        <svg width="9" height="10" viewBox="0 0 9 10" fill="currentColor"><polygon points="0,0 9,5 0,10"/></svg>
        Preview
      </button>
      <button class="btn-track-restart" title="Restart preview"><svg width="13" height="10" viewBox="0 0 13 10" fill="currentColor"><polygon points="6,0 6,10 0,5"/><polygon points="13,0 13,10 7,5"/></svg></button>
      <button class="btn-track-save" title="Save this layback">
        <svg width="12" height="13" viewBox="0 0 12 13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="6" y1="1" x2="6" y2="9"/><polyline points="2,5 6,9 10,5"/><line x1="1" y1="12" x2="11" y2="12"/></svg>
      </button>
    </div>`;

  const btnRemoveEl   = card.querySelector('.tbtn-remove');
  const btnPreviewEl  = card.querySelector('.btn-track-preview');
  const btnRestartEl  = card.querySelector('.btn-track-restart');
  const btnSaveEl     = card.querySelector('.btn-track-save');
  const trimSlider   = card.querySelector('.s-trim');
  const trimVal      = card.querySelector('.trim-val');
  const tw           = card.querySelector('.tw');
  const twWrap       = card.querySelector('.tw-wrap');
  const twTrimIn     = card.querySelector('.tw-trim-in');
  const twTrimOut    = card.querySelector('.tw-trim-out');
  const twTrimTip    = card.querySelector('.tw-trim-tip');

  /**
   * Track waveform click → seek. Shared by the canvas itself and by the trim band
   * that sits on top of it, so both read the click the same way.
   *
   * The canvas spans the WHOLE song, but only [aIn, aOut] is playable —
   * syncTrackEndToVideo auto-caps the Out handle to the active video window, so on
   * the usual case (a 3-minute song under a 40-second cut) most of the waveform is
   * dimmed and unreachable. A click out there is IGNORED rather than clamped back
   * to the edge: clamping is what landed the music earlier than the point clicked
   * (#341). The other half of that bug was feeding a TRACK time straight into
   * doSeek(), which wants a VIDEO time — hence videoTimeForTrack. The band's edges
   * are placed as a CSS percentage, so its outermost pixel column can read a hair
   * outside [aIn, aOut]; one pixel's worth of song is tolerated either way —
   * otherwise the first and last pixel of the band become dead zones — then snapped
   * back inside. aOut ≤ videoCapForTrack = aIn + (vOut − mStart), so an in-band
   * track time always maps inside the video window — doSeek's own clamp is a no-op
   * here.
   * @param {number} clientX - Click X in viewport coordinates.
   * @returns {void}
   */
  function seekFromTrackWave(clientX) {
    const t   = trackMap.get(id);
    const dur = trackDuration(t);
    if (!videoLoaded || !videoEl.duration || !dur) return;
    const rect   = tw.getBoundingClientRect();
    const trackT = clamp01((clientX - rect.left) / rect.width) * dur;
    const inT = aIn(t), outT = aOut(t);
    const tol = dur / rect.width;
    if (trackT < inT - tol || trackT > outT + tol) return;             // dimmed = inert
    doSeek(videoTimeForTrack(t, Math.max(inT, Math.min(trackT, outT))));
  }

  /*
   * Canvas click: activate this track (no-op if already active — even out in the
   * dim), then seek. Skipped when a handle drag just ended over the canvas;
   * stopPropagation prevents the card handler from calling setActiveTrack twice.
   */
  tw.addEventListener('click', e => {
    if (_trimDragging) return;
    e.stopPropagation();
    setActiveTrack(id);
    seekFromTrackWave(e.clientX);
  });

  /*
   * Per-track audio trim handles — independent In/Out stored on this track's map
   * entry. The Out handle edits the user's saved *target* (trimEndTarget); the
   * effective Out (trimEnd) is then derived against the video cap by
   * syncTrackEndToVideo (which relayout re-runs). getEnd returns the effective end,
   * used for In-clamping. A plain click on the band seeks — the band covers the
   * playable region, so it shares the canvas's handler verbatim (the band swallows
   * the click before it reaches the canvas).
   */
  attachTrimDrag({
    rectEl: twWrap, handleIn: twTrimIn, handleOut: twTrimOut, tooltipEl: twTrimTip,
    handleRegion: card.querySelector('.tw-region'),
    getDuration: () => trackDuration(trackMap.get(id)),
    getStart: () => trackMap.get(id)?.trimStart || 0,
    getEnd:   () => trackMap.get(id)?.trimEnd ?? null,
    getMaxEnd: () => Math.min(trackDuration(trackMap.get(id)), videoCapForTrack(trackMap.get(id))),
    setStart: s => { const t = trackMap.get(id); if (t) t.trimStart = s; },
    setEnd:   e => { const t = trackMap.get(id); if (t) t.trimEndTarget = e; },
    relayout: () => syncTrackEndToVideo(id),
    onRegionClick: cx => {
      setActiveTrack(id);
      seekFromTrackWave(cx);
    },
  });
  /*
   * Once the track's duration is known, lay out its handles and auto-fit the Out
   * handle to the active video section (no-op when no video is loaded — handles
   * default to full).
   */
  audio.addEventListener('loadedmetadata', () => syncTrackEndToVideo(id));
  if (audio.duration) syncTrackEndToVideo(id);

  // Web Audio graph: MediaElementSource → GainNode (trim) → destination. The node is
  // stored on the map entry so applyInitialGain can reach it.
  const audioCtx = getAudioCtx();
  const gainNode  = audioCtx.createGain();
  audioCtx.createMediaElementSource(audio).connect(gainNode);
  gainNode.connect(audioCtx.destination);
  trackMap.get(id).gainNode = gainNode;

  // Show placeholder immediately; real peaks fill in after decode
  requestAnimationFrame(() => drawWavePlaceholder(tw, seed, '#283545'));
  analyzeTrack(id, trackMap.get(id).blobUrl);

  // When track audio finishes, reset its waveform but let the video keep rolling
  audio.addEventListener('ended', () => resetTrackWaveform(id));

  card.addEventListener('click', e => {
    if (e.target.classList.contains('tbtn') || e.target.tagName === 'INPUT') return;
    setActiveTrack(id);
  });

  btnRemoveEl.addEventListener('click', e => {
    e.stopPropagation();
    removeTrack(id);
  });

  /*
   * Volume slider: smooth glide (it's a drag). setTrackGain also re-anchors any
   * scheduled tail fade at the new level, so the fade starts from where the slider
   * now sits (#342).
   */
  trimSlider.addEventListener('input', function() {
    const v = parseFloat(this.value);
    trimVal.textContent = v === 0 ? '0' : (v > 0 ? '+' + v : v);
    setTrackGain(trackMap.get(id), id, Math.pow(10, v / 20), { smooth: true });
    markAutomixReady();
  });

  btnPreviewEl.addEventListener('click', e => {
    e.stopPropagation();
    if (isPreviewing && activeTrackId === id) stopPreview();
    else { setActiveTrack(id); startPreview(); }
  });

  /*
   * Restart: seek to the trim In point (0 when untrimmed). Playing → startPreview
   * seeks the track audio and replays from the In point; paused → move the scrub
   * bar and waveform playheads immediately, parking the track via parkTrackAt (its
   * In point when the marker is later).
   */
  btnRestartEl.addEventListener('click', e => {
    e.stopPropagation();
    const wasPlaying = isPreviewing;
    videoEl.currentTime = vIn();
    if (wasPlaying) {
      startPreview();
    } else {
      const pct = videoEl.duration ? vIn() / videoEl.duration : 0;
      scrubFill.style.width = (pct * 100) + '%';
      scrubHead.style.left  = (pct * 100) + '%';
      resetVofxWaveform();
      const t = trackMap.get(id);
      if (t) {
        t.audio.currentTime = parkTrackAt(t, vIn());
        resetTrackWaveform(id);
      }
    }
  });

  /*
   * Quick-save (↓): export this one track's layback straight to Downloads,
   * bypassing the Save modal. The name affix is read here too — otherwise the same
   * track saved via ↓ and via the modal would come out with different filenames. No
   * overwrite pre-check is needed: the worker enumerates a Finder-style " (n)"
   * suffix on any existing file, so a quick-save never clobbers without a rename.
   * Metadata is stamped onto the saved file before confirming (non-fatal if it
   * fails); confirmation is an overt toast since the status line is hidden.
   */
  btnSaveEl.addEventListener('click', async e => {
    e.stopPropagation();
    if (!requireCanSave()) return;
    const t = trackMap.get(id);
    if (!videoLoaded || !currentVideoPath) {
      showError('Load a video before saving');
      return;
    }
    if (!t?.filePath) {
      showError('Track path unavailable — reload the file');
      return;
    }
    const db    = trimSlider ? parseFloat(trimSlider.value) : 0;
    const level = Math.pow(10, db / 20);
    btnSaveEl.disabled = true;
    try {
      const downloadsPath = await window.ezl.getDownloadsPath();
      const quickCfg = await window.ezl.getSettings();

      statusText.textContent = 'Saving layback…';
      const result = await window.ezl.exportLaybacks({
        video_path:   currentVideoPath,
        audio_paths:  [t.filePath],
        output_dir:   downloadsPath,
        added_text:     quickCfg.files.nameAffix.text,
        added_text_pos: quickCfg.files.nameAffix.position,
        oa_peak_dbfs: oaPeakDbfs,
        oa_enabled:   oaEnabled,
        // OA replacement: a separate file stands in for the video's embedded stream (null =
        // use embedded). video_duration lets the worker crop the replacement to the video end.
        oa_replace_path: oaReplacePath,
        video_duration:  videoEl.duration || 0,
        audio_levels: [level],
        audio_codec:  'aac',
        video_codec:  'copy',
        // Trim window in seconds; null end means "no out-trim" to the backend
        video_trim_start:  videoTrimStart || 0,
        video_trim_end:    videoTrimEnd,
        audio_trim_starts: [t.trimStart || 0],
        audio_trim_ends:   [t.trimEnd],
        // Music Start: seconds of silence before this track comes in, measured from the
        // layback's own zero (the video In point). 0 = music starts with picture. Sent as a
        // relative delay rather than an absolute marker time because the worker rebases each
        // track to t=0 of the OUTPUT timeline (atrim + asetpts=PTS-STARTPTS), so a delay is
        // exactly what its filter graph consumes — no video_trim_start arithmetic repeated
        // across the IPC boundary.
        audio_delays:      [Math.max(0, mStart() - vIn())],
        // Tail fade-out matching the preview fade (only when track outlasts the video)
        audio_fade_outs:   [exportFadeOutFor(t)],
      });
      if (result.status === 'ok') {
        await applyExportMetadata(result.output_paths, quickCfg);
        const filename = result.display_names?.[0]
          ?? result.output_paths[0]?.split(/[\\/]/).pop()
          ?? 'layback';
        statusText.textContent = `Saved ${filename} → Downloads`;
        showToast(`Saved ${filename} → Downloads`);
      } else {
        // result.error is the friendly message; result.error_detail is the raw FFmpeg text.
        showError(result.error ?? 'The layback couldn\'t be saved.', result.error_detail);
      }
    } catch (err) {
      showError('The layback couldn\'t be saved.', err.message);
    } finally {
      btnSaveEl.disabled = false;
    }
  });

  return card;
}

/**
 * Add one track from a { path, url, name } descriptor. Shared by fresh import
 * (addTrackFiles) and project restore. The audio element's crossOrigin is set BEFORE
 * src: an ezl-file:// source loaded without CORS taints the element, and its
 * MediaElementSource then outputs pure silence (see videoEl's markup note). Its
 * volume is seeded from the Master Volume slider's current position — that handler
 * only fires on 'input', so a track added after the user moved it (or the 75%
 * default on launch) is honoured immediately instead of playing at full volume until
 * the next slider move. The extension (which drives the AIFF backend-preview
 * routing) is derived from the PATH basename — the restored display name has its
 * extension stripped, which previously made AIFF miss on restore (the raw AIFF then
 * loaded over ezl-file://, undecodable → no duration → trim stuck at 0). On restore
 * the saved per-track volume is applied to the slider + GainNode (createTrackCard
 * has already created the GainNode synchronously); fresh imports keep the unity
 * default. Fresh import auto-activates the first track; on restore the active track
 * is chosen by path afterwards (so a project with no saved active track opens with
 * none selected). Python loudness analysis is kicked off but gain is NOT
 * auto-applied — the saved/auto value stands and AUTO-MIX merely glows "ready" —
 * matching fresh-import behaviour. Readiness: OA mode when OA is analyzed; no-OA
 * mode on track data alone; no-video mode when 2+ tracks are analyzed (levels
 * relative to each other).
 * @param {{path: (string|null), url: string, name: string}} desc - Import
 *   descriptor (blob: URL for fresh import, ezl-file:// on restore).
 * @param {{volumeDb: number, trimStart: number, trimEndTarget: (number|null)}}
 *   [restore] - Saved session state, applied after the card is built.
 * @returns {number} The new track id.
 */
function addTrackDescriptor(desc, restore) {
  const id       = ++trackIdCounter;
  const blobUrl  = desc.url;
  const audio    = new Audio();
  audio.crossOrigin = 'anonymous';
  audio.src      = blobUrl;
  audio.volume   = parseInt(document.getElementById('masterVol').value) / 100;
  const seed     = id * 137 + 42;
  const filePath = desc.path;
  const extBase  = (desc.path || desc.name || '').split(/[\\/]/).pop();
  const ext      = (extBase.includes('.') ? extBase.split('.').pop() : '').toLowerCase();
  trackMap.set(id, {
    audio, blobUrl, previewUrl: null, ext, seed, peaks: null, filePath,
    lufs: null, peak_dbfs: null,
    trimStart:     restore ? (restore.trimStart || 0) : 0,
    trimEnd:       null,                              // effective end — derived by syncTrackEndToVideo
    trimEndTarget: restore ? (restore.trimEndTarget ?? null) : null,
  });
  const card = createTrackCard(id, stripExt(desc.name), seed, audio);
  tracksList.appendChild(card);

  if (restore && typeof restore.volumeDb === 'number') {
    const v       = Math.max(-24, Math.min(6, restore.volumeDb));
    const slider  = card.querySelector('.s-trim');
    const trimVal = card.querySelector('.trim-val');
    slider.value  = v;
    trimVal.textContent = v === 0 ? '0' : (v > 0 ? '+' + v : String(v));
    const t = trackMap.get(id);
    if (t.gainNode) t.gainNode.gain.value = AutoMix.dbToLinear(v);
  }

  if (!restore && trackMap.size === 1) setActiveTrack(id);

  if (filePath && window.ezl) {
    window.ezl.analyzeLoudness(filePath).then(result => {
      const t = trackMap.get(id);
      if (!t) return;
      if (result.status === 'ok') {
        t.lufs     = result.lufs;
        t.peak_dbfs = result.peak_dbfs;
        console.log(`[track] ${desc.name}: ${result.lufs} LUFS  peak ${result.peak_dbfs} dBFS  ${result.duration_sec}s`);
        const analyzedCount = [...trackMap.values()].filter(t => t.lufs !== null).length;
        if (oaLufs !== null || !oaEnabled || (!videoLoaded && analyzedCount >= 2))
          markAutomixReady();
      } else {
        console.warn(`[track] ${desc.name}: analysis ${result.status} — ${result.reason ?? result.error ?? ''}`);
      }
    }).catch(err => console.warn('[analyzeLoudness]', err));
  }
  return id;
}

/**
 * Import a batch of dropped/picked files as tracks, skipping non-audio files.
 * Errors if none are supported.
 * @param {FileList|File[]} files - Candidate files.
 * @returns {void}
 */
function addTrackFiles(files) {
  const audioFiles = Array.from(files).filter(isAudioFile);
  if (!audioFiles.length) {
    showError('No supported audio files — try WAV, AIFF, MP3, M4A, or MP4');
    return;
  }
  audioFiles.forEach(file => addTrackDescriptor(descriptorFromFile(file)));
  updateHeader();
  updatePreviewBtn();
}

// Track drop zone — click
trackDrop.addEventListener('click', () => trackFileInput.click());
tracksEmpty.addEventListener('click', () => trackFileInput.click());
/*
 * Empty area below the loaded track cards stays clickable — only clicks that land
 * on the list's bare background count; card clicks bubble up and must keep their
 * own behavior (activate, sliders, remove).
 */
tracksList.addEventListener('click', e => {
  if (e.target === tracksList) trackFileInput.click();
});
trackFileInput.addEventListener('change', e => {
  if (e.target.files.length) addTrackFiles(e.target.files);
  e.target.value = '';
});

// Track drop zone — drag/drop onto panel or drop zone
panelRight.addEventListener('dragover', e => {
  e.preventDefault();
  e.stopPropagation();
  trackDrop.classList.add('drag-over');
});
panelRight.addEventListener('dragleave', e => {
  if (!panelRight.contains(e.relatedTarget)) trackDrop.classList.remove('drag-over');
});
panelRight.addEventListener('drop', e => {
  e.preventDefault();
  e.stopPropagation();
  trackDrop.classList.remove('drag-over');
  if (e.dataTransfer.files.length) addTrackFiles(e.dataTransfer.files);
});

/** MARK: AUTO-MIX */

// AUTO-MIX recalculates every analyzed track's gain, then clears the ready glow.
document.getElementById('btnAutomix').addEventListener('click', () => {
  trackMap.forEach((t, id) => {
    if (t.lufs !== null) applyInitialGain(id);
  });
  clearAutomixReady();
});

/** MARK: TOOLBAR SLIDERS */

let _lastMusicLevel = parseInt(document.getElementById('musicLevel').value);
/*
 * Music Level slider: shift every calibrated track by the slider delta, preserving
 * manual trims. Each track keeps an un-clamped logical gain so an offset pushed past
 * a rail (-24/+6) is restored intact when the slider sweeps back; the slider value
 * is re-adopted whenever an external write (Auto-Mix or a manual trim drag) has
 * moved it off the last clamped view. Gains are written smooth (it's a drag), which
 * also rebuilds any scheduled tail fade at the new level (#342).
 */
document.getElementById('musicLevel').addEventListener('input', function() {
  const newLevel = parseInt(this.value);
  const delta    = newLevel - _lastMusicLevel;
  _lastMusicLevel = newLevel;
  document.getElementById('mlVal').textContent = this.value;

  trackMap.forEach((t, id) => {
    if (t.lufs === null) return;
    const card = document.querySelector(`.track-card[data-track-id="${id}"]`);
    if (!card) return;
    const slider = card.querySelector('.s-trim');
    const trimVal = card.querySelector('.trim-val');
    const cur = parseFloat(slider.value);
    if (t.logicalGainDb === undefined ||
        Math.abs(Math.max(-24, Math.min(6, t.logicalGainDb)) - cur) > 1e-6) {
      t.logicalGainDb = cur;
    }
    t.logicalGainDb += delta;
    const v = Math.max(-24, Math.min(6, t.logicalGainDb));
    slider.value = v;
    trimVal.textContent = v === 0 ? '0' : (v > 0 ? '+' + v.toFixed(1) : v.toFixed(1));
    setTrackGain(t, id, Math.pow(10, v / 20), { smooth: true });
  });
});
// Master Volume: monitor-only — drives the video element, every track element, and
// the replacement OA; never written to export.
document.getElementById('masterVol').addEventListener('input', function() {
  document.getElementById('mvVal').textContent = this.value + '%';
  const vol = this.value / 100;
  if (videoLoaded) videoEl.volume = vol;
  trackMap.forEach(({ audio }) => { audio.volume = vol; });
  if (oaReplaceAudio) oaReplaceAudio.volume = vol;
});
// Waveform zoom — live-tunes --waveform-scale (100..400 → 1.0..4.0×), then repaints
// all canvases (a CSS-var change alone won't trigger the resize-based redraw).
document.getElementById('waveZoom').addEventListener('input', function() {
  const scale = this.value / 100;
  document.getElementById('wzVal').textContent = scale.toFixed(1) + '×';
  document.documentElement.style.setProperty('--waveform-scale', scale);
  redrawAllWaveforms();
});

/** MARK: ORIGINAL AUDIO TOGGLE */

/**
 * Drive the OA toggle to an explicit state (used by the click handler and by project
 * restore). OFF mutes the OA gain node and — since no-OA mode changes the mix
 * reference — lights AUTO-MIX if any tracks are already analyzed. ON restores the
 * normalisation gain and lights AUTO-MIX when OA is analyzed and tracks are loaded
 * (switching INTO OA mode makes OA the anchor, so a recalc is pending — mirroring
 * the OFF branch and the OA-analysis path).
 * @param {boolean} enabled - Desired OA state.
 * @returns {void}
 */
function setOaEnabled(enabled) {
  const toggle = document.getElementById('oaToggle');
  const thumb  = toggle.querySelector('.toggle-thumb');
  const label  = document.getElementById('oaLabel');
  if (!enabled) {
    toggle.style.background = '#1E2530'; thumb.style.right = '14px';
    label.style.color = 'var(--text-dim)'; label.textContent = 'OFF'; toggle.dataset.off = '1';
    oaEnabled = false;
    if (oaGainNode) oaGainNode.gain.value = 0.0;
    updateMusicLevelVisibility();
    if ([...trackMap.values()].some(t => t.lufs !== null)) markAutomixReady();
  } else {
    toggle.style.background = 'var(--green)'; thumb.style.right = '2px';
    label.style.color = '#2E7D4F'; label.textContent = 'ON'; delete toggle.dataset.off;
    oaEnabled = true;
    if (oaGainNode) oaGainNode.gain.value = oaNormGain;
    updateMusicLevelVisibility();
    if (oaLufs !== null && trackMap.size > 0) markAutomixReady();
  }
}
document.getElementById('oaToggle').addEventListener('click', function() {
  setOaEnabled(!!this.dataset.off); // dataset.off set → currently OFF → turn ON, and vice-versa
});

/** MARK: PREVIEW */

/**
 * Light the AUTO-MIX button's ready glow.
 * @returns {void}
 */
function markAutomixReady()  { document.getElementById('btnAutomix').classList.add('ready'); }

/**
 * Clear the AUTO-MIX button's ready glow.
 * @returns {void}
 */
function clearAutomixReady() { document.getElementById('btnAutomix').classList.remove('ready'); }

/**
 * Show the Music Level slider only in OA mode — hidden when OA is absent or
 * disabled.
 * @returns {void}
 */
function updateMusicLevelVisibility() {
  const show = oaEnabled && oaLufs !== null;
  document.getElementById('musicLevelGroup').style.display   = show ? '' : 'none';
  document.getElementById('musicLevelDivider').style.display = show ? '' : 'none';
}

/**
 * The active track card's Preview button, or null when no track is active.
 * @returns {HTMLButtonElement|null}
 */
function getActivePreviewBtn()  { return document.querySelector('.track-card.active .btn-track-preview'); }

/**
 * The active track card's Restart button, or null when no track is active.
 * @returns {HTMLButtonElement|null}
 */
function getActiveRestartBtn()  { return document.querySelector('.track-card.active .btn-track-restart'); }

/**
 * Grey out the Restart buttons while the playhead is at the start. Restart seeks to
 * the trim In point, so "at start" is measured from vIn() (not absolute 0);
 * otherwise a video trimmed past 0.5s never greys the button when parked at the In
 * point.
 * @returns {void}
 */
function syncRestartBtn() {
  const atStart = !videoLoaded || videoEl.currentTime < vIn() + 0.5;
  btnRestart.disabled = atStart;
  const btn = getActiveRestartBtn();
  if (btn) btn.disabled = atStart;
}

/**
 * Enable/disable the active card's Preview button based on whether a video is
 * loaded, tearing down a running preview when it becomes unavailable.
 * @returns {void}
 */
function updatePreviewBtn() {
  const ready = videoLoaded;
  const btn = getActivePreviewBtn();
  if (btn) btn.disabled = !ready;
  if (!ready && isPreviewing) pauseAll();
}

/**
 * Plain (no active track) video playback, confined to the trim window. A fresh
 * start outside the window (incl. at 0 or parked at Out) begins at the In point.
 * Starts the replacement OA in sync (no-op if none) and arms the animation loop.
 * @returns {void}
 */
function playVideoBounded() {
  if (videoEl.currentTime < vIn() || videoEl.currentTime >= vOut() - 0.001)
    videoEl.currentTime = vIn();
  _anchorVideo();
  videoEl.play();
  oaReplacePlayFrom(videoEl.currentTime);
  btnPlay.innerHTML = '<span>&#x23F8;</span>';
  startWaveformAnimation();
}

/**
 * Start preview: video + active track (+ replacement OA) from the current scrub
 * position. If the video ran all the way to its end it rewinds to 00:00 before
 * computing sync positions — browsers leave currentTime at duration after 'ended',
 * so without this both video and track would try to start from the last frame. The
 * start position is clamped into the video trim window so preview begins at the In
 * point and never starts past Out (Out enforcement happens in the rAF loop). The
 * track is seeked to its offset position so the two start together — unless the
 * Music Start marker sits ahead of where the video is starting, in which case the
 * track must NOT sound yet: it's parked pre-seeked at its own In point and the video
 * starts alone, with the rAF audibility gate bringing it in with a bare play() when
 * the playhead reaches the marker (without this, the target would be below aIn —
 * possibly negative — and the clamp would start the trimmed-off head immediately).
 * The audio seek is awaited before playing (audio.play() can otherwise start from
 * the wrong position on tracks that haven't been played before, or when resuming
 * mid-video; an identity seek resolves immediately to avoid a hung seeked-await).
 * All elements then start simultaneously, and playback is confirmed before the
 * animation loop launches — otherwise the loop could see paused=true on its first
 * tick and immediately self-terminate. Bails at each await if the user switched
 * tracks, removed this one, or stopped. Finally schedules the tail fade if the
 * track outlasts the video trim window.
 * @returns {Promise<void>}
 */
async function startPreview() {
  const t = trackMap.get(activeTrackId);
  if (!t) return;
  pauseAll(); // resets isPreviewing=false and button state

  if (videoEl.ended) videoEl.currentTime = 0;

  if (videoEl.currentTime < vIn() || videoEl.currentTime >= vOut()) {
    videoEl.currentTime = vIn();
  }

  const startId  = activeTrackId;
  const trackDur = t.audio.duration || 0;
  const target   = trackTimeFor(t, videoEl.currentTime);
  const startsSilent = videoEl.currentTime < mStart() - 0.001;
  const syncTime = startsSilent
    ? aIn(t)
    : Math.max(0, trackDur > 0 ? Math.min(target, trackDur) : target);

  await new Promise(resolve => {
    if (Math.abs(t.audio.currentTime - syncTime) < 0.05) { resolve(); return; }
    t.audio.addEventListener('seeked', resolve, { once: true });
    t.audio.currentTime = syncTime;
  });

  if (activeTrackId !== startId || !trackMap.has(startId)) return;

  _anchorVideo();
  _anchorAudio(t.audio);
  const playPromises = [videoEl.play()];
  if (!startsSilent) playPromises.push(t.audio.play()); // silent pre-roll before the marker: video only
  const oaPlay = oaReplacePlayFrom(videoEl.currentTime);
  if (oaPlay) playPromises.push(oaPlay);
  await Promise.all(playPromises);

  if (activeTrackId !== startId || !trackMap.has(startId)) return;

  isPreviewing = true;
  btnPlay.innerHTML = '<span>&#x23F8;</span>';
  const btn = getActivePreviewBtn();
  if (btn) { btn.innerHTML = '&#x25A0; Stop'; btn.classList.add('previewing'); }
  scheduleTrackFade(t, videoEl.currentTime, vOut());
  startWaveformAnimation();
}

/**
 * Stop preview. pauseAll resets isPreviewing and the button label.
 * @returns {void}
 */
function stopPreview() {
  pauseAll();
}

/** MARK: SAVE LAYBACKS MODAL */

/*
 * Save Laybacks opens the modal after pre-flight checks (entitlement, video + at
 * least one track loaded, every track path available). Folder precedence: a folder
 * the user picked THIS session (lastOutputDir) wins, so their in-session choice is
 * sticky; otherwise the persisted default from Settings — get-settings resolves an
 * unset default to ~/Downloads, so that single value already covers the "no
 * configured default" case. The modal is then populated and reset to its initial
 * ready state; the path fit must run AFTER showModal, since a closed dialog has no
 * layout width to measure.
 */
document.getElementById('btnSave').addEventListener('click', async () => {
  if (!requireCanSave()) return;
  if (!videoLoaded || !currentVideoPath) {
    showError('Load a video before saving');
    return;
  }
  if (trackMap.size === 0) {
    showError('Add at least one track before saving');
    return;
  }
  const trackEntries = [...trackMap.entries()];
  if (trackEntries.some(([, t]) => !t.filePath)) {
    showError('Some track paths are unavailable — reload the file');
    return;
  }

  const cfg = await window.ezl.getSettings();
  modalDir = lastOutputDir ?? cfg.files.defaultSaveFolder; // lastOutputDir is only ever set from user-chosen folders

  nameAffixText = cfg.files.nameAffix.text;
  nameAffixPos = cfg.files.nameAffix.position;

  document.getElementById('saveModalFolderPath').textContent = modalDir;
  document.getElementById('saveModalTracks').innerHTML = trackEntries
    .map(([, t]) => `<div class="modal-track-item">${escHtml(t.name ?? t.filePath.split(/[\\/]/).pop())}</div>`)
    .join('');
  const statusEl  = document.getElementById('saveModalStatus');
  const btnGo     = document.getElementById('btnSaveModalGo');
  const btnDone   = document.getElementById('btnSaveModalDone');
  const btnCancel = document.getElementById('btnSaveModalCancel');
  const btnChange = document.getElementById('btnChangeFolder');
  statusEl.innerHTML = '';
  statusEl.classList.remove('error');
  btnGo.textContent  = 'GO';
  btnGo.disabled     = false;
  btnGo.classList.remove('hidden');
  btnDone.classList.add('hidden');
  btnCancel.classList.add('hidden');
  btnCancel.disabled = false;
  btnChange.disabled = false;
  exportCancelled    = false;
  userSelectedFolder = false;
  lastExportError    = null;
  lastErrorDetail    = null;

  document.getElementById('saveModal').showModal();
  fitFolderPath(document.getElementById('saveModalFolderPath'), modalDir);
});

/**
 * Fit a filesystem path into a fixed-width, single-line field, favoring the TAIL —
 * the folder that actually receives the files. Whole leading path segments are
 * dropped one at a time (never chopping a name mid-word) and "…/" is prefixed, so a
 * long path reads e.g. "…/VS_CODE/EZL_Redux/Laybacks_k7mq". If even the final
 * segment alone overflows (a single folder name wider than the row), the "…/<name>"
 * candidate is left and the CSS ellipsis clips its tail — rare edge. Relies on live
 * layout, so the element must be visible when called (a closed dialog reports zero
 * width). Shared by the Save and Settings modals.
 * @param {HTMLElement} el - Field to write into.
 * @param {string} dir - Full path to fit (Windows-style separators honored).
 * @returns {void}
 */
function fitFolderPath(el, dir) {
  el.textContent = dir;
  if (el.scrollWidth <= el.clientWidth) return;
  const sep   = dir.includes('\\') ? '\\' : '/';
  const parts = dir.split(sep);
  for (let i = 1; i < parts.length; i++) {
    el.textContent = '…' + sep + parts.slice(i).join(sep);
    if (el.scrollWidth <= el.clientWidth) return;
  }
}

// "Change…" — lets the user pick a different destination folder
document.getElementById('btnChangeFolder').addEventListener('click', async () => {
  const chosen = await window.ezl.showFolderPicker();
  if (chosen) {
    modalDir = chosen;
    userSelectedFolder = true;
    fitFolderPath(document.getElementById('saveModalFolderPath'), modalDir);
  }
});

// "×" close button — cancels any in-flight export and closes
document.getElementById('btnSaveModalClose').addEventListener('click', () => {
  exportCancelled = true;
  document.getElementById('saveModal').close();
});

/*
 * Escape dismisses the native <dialog> on its own (fires 'cancel', then closes).
 * Mirror the × button so a mid-export Escape cancels the batch too; without this
 * the modal vanishes while the GO loop keeps writing the remaining files.
 */
document.getElementById('saveModal').addEventListener('cancel', () => {
  exportCancelled = true;
});

// "Close" button — only visible after a full export failure
document.getElementById('btnSaveModalDone').addEventListener('click', () => {
  document.getElementById('saveModal').close();
});

// Cancel button — sets flag; the GO loop checks it between tracks
document.getElementById('btnSaveModalCancel').addEventListener('click', () => {
  exportCancelled = true;
  document.getElementById('btnSaveModalCancel').disabled = true;
});

/*
 * GO — exports one track at a time so the progress counter can advance. More than
 * one layback gets wrapped in an EZL_<video> subfolder so the batch stays together;
 * a single layback writes straight into the chosen parent (no wrapper). The worker
 * creates whichever directory is sent and enumerates a Finder-style " (n)" suffix on
 * any filename collision, so nothing is clobbered and no overwrite pre-check is
 * needed. Settings feed the post-export metadata stamp (artist/album/artwork/etc),
 * read once per batch — the open-handler's cfg is a different scope, so it can't be
 * reused here. Per-file: the list auto-scrolls so the file being written stays in
 * view ('nearest' scrolls only the list, and only when the item isn't already
 * visible, so the done/failed colour and the " → filename" suffix land in view);
 * successes stamp metadata (non-fatal) and record the worker's show_in_finder_path
 * (a written file — showItemInFolder opens its containing folder with it selected;
 * the last one feeds the Show-in-Finder link); failures record the friendly error
 * (shown) and the raw detail (copied via the copy button). Completion shows saved
 * count + Show in Finder, "Export cancelled." with GO restored, or — on full
 * failure — the error with a copy button and GO swapped for Close. Only explicitly
 * user-chosen folders are remembered for the next open.
 */
document.getElementById('btnSaveModalGo').addEventListener('click', async () => {
  if (exportInProgress) return; // guard against double-fire during completion state
  exportInProgress = true;

  const trackEntries = [...trackMap.entries()];
  const total        = trackEntries.length;
  const btnGo        = document.getElementById('btnSaveModalGo');
  const btnDone      = document.getElementById('btnSaveModalDone');
  const btnCancel    = document.getElementById('btnSaveModalCancel');
  const btnChange    = document.getElementById('btnChangeFolder');
  const statusEl     = document.getElementById('saveModalStatus');
  const items        = document.getElementById('saveModalTracks').querySelectorAll('.modal-track-item');

  if (total > 1) {
    modalDir = await genEzlFolder(modalDir, currentVideoPath);
    fitFolderPath(document.getElementById('saveModalFolderPath'), modalDir);
  }

  btnGo.disabled     = true;
  btnChange.disabled = true;
  btnCancel.classList.remove('hidden');
  exportCancelled    = false;
  lastExportError    = null;
  lastErrorDetail    = null;

  let savedCount = 0;
  let failedCount = 0;
  let lastRevealPath = modalDir // replaced with worker's show_in_finder_path from the last successful export

  const cfg = await window.ezl.getSettings();

  for (let i = 0; i < total; i++) {
    if (exportCancelled) break;

    const [id, t] = trackEntries[i];
    btnGo.textContent    = `${i + 1} / ${total}`;
    statusEl.textContent = `Saving layback ${i + 1} of ${total}…`;
    items[i]?.scrollIntoView({ block: 'nearest' });

    const card  = document.querySelector(`.track-card[data-track-id="${id}"]`);
    const db    = parseFloat(card?.querySelector('.s-trim')?.value ?? '0');
    const level = Math.pow(10, db / 20);

    try {
      const result = await window.ezl.exportLaybacks({
        video_path:   currentVideoPath,
        audio_paths:  [t.filePath],
        output_dir:   modalDir,
        added_text:    nameAffixText,
        added_text_pos: nameAffixPos,
        oa_peak_dbfs: oaPeakDbfs,
        oa_enabled:   oaEnabled,
        // OA replacement: a separate file stands in for the video's embedded stream (null =
        // use embedded). video_duration lets the worker crop the replacement to the video end.
        oa_replace_path: oaReplacePath,
        video_duration:  videoEl.duration || 0,
        audio_levels: [level],
        audio_codec:  'aac',
        video_codec:  'copy',
        // Trim window in seconds; null end means "no out-trim" to the backend
        video_trim_start:  videoTrimStart || 0,
        video_trim_end:    videoTrimEnd,
        audio_trim_starts: [t.trimStart || 0],
        audio_trim_ends:   [t.trimEnd],
        // Music Start: seconds of silence before this track comes in, measured from the
        // layback's own zero (the video In point). 0 = music starts with picture. Sent as a
        // relative delay rather than an absolute marker time because the worker rebases each
        // track to t=0 of the OUTPUT timeline (atrim + asetpts=PTS-STARTPTS), so a delay is
        // exactly what its filter graph consumes — no video_trim_start arithmetic repeated
        // across the IPC boundary.
        audio_delays:      [Math.max(0, mStart() - vIn())],
        // Tail fade-out matching the preview fade (only when track outlasts the video)
        audio_fade_outs:   [exportFadeOutFor(t)],
      });
      if (result.status === 'ok') {
        await applyExportMetadata(result.output_paths, cfg);
        lastRevealPath = result.show_in_finder_paths?.[0] ?? lastRevealPath;
        const displayName = result.display_names?.[0] ?? 'layback';
        savedCount++;
        if (items[i]) items[i].textContent += ` → ${displayName}`;
        items[i]?.classList.add('done');
      } else {
        failedCount++;
        lastExportError = result.error ?? 'Export failed';           // friendly (shown)
        lastErrorDetail = result.error_detail ?? result.error ?? ''; // raw (copied)
        items[i]?.classList.add('failed');
      }
    } catch (err) {
      failedCount++;
      lastExportError = 'The layback couldn\'t be saved.';
      lastErrorDetail = err?.message ?? 'Unknown error';
      items[i]?.classList.add('failed');
    }
  }

  // Completion state
  exportInProgress = false;
  btnCancel.classList.add('hidden');
  btnChange.disabled = false;

  if (savedCount > 0) {
    if (userSelectedFolder) lastOutputDir = modalDir; // only remember explicit user-chosen folders
    const label = savedCount === 1 ? '1 layback saved.' : `${savedCount} laybacks saved.`;
    statusEl.classList.remove('error');
    statusEl.innerHTML =
      `${label} <button class="btn-reveal" id="btnReveal">Show in Finder</button>`;
    document.getElementById('btnReveal').addEventListener('click', () =>
      window.ezl.revealInFolder(lastRevealPath ?? modalDir)
    );
    btnGo.classList.add('hidden');
    btnDone.textContent = 'Done';
    btnDone.classList.remove('hidden');
  } else if (exportCancelled) {
    statusEl.classList.remove('error');
    statusEl.textContent = 'Export cancelled.';
    btnGo.disabled    = false;
    btnGo.textContent = 'GO';
  } else {
    statusEl.classList.add('error');
    statusEl.innerHTML =
      escHtml(lastExportError ?? 'Export failed.') +
      ' <button class="btn-copy-error" id="btnCopyError" title="Copy error">' +
      '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">' +
      '<rect x="9" y="9" width="13" height="13" rx="2"/>' +
      '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>' +
      '</svg></button>';
    document.getElementById('btnCopyError').addEventListener('click', () =>
      navigator.clipboard.writeText(lastErrorDetail ?? lastExportError ?? '')
    );
    btnGo.classList.add('hidden');
    btnDone.textContent = 'Close';
    btnDone.classList.remove('hidden');
  }
});

// Initial state: no video loaded, so Music Level slider is hidden until OA is present
updateMusicLevelVisibility();

/**
 * MARK: SAVE / OPEN PROJECT — persists a working session — which video + tracks are
 * loaded, their levels and trim windows, the Music Level offset, and the active
 * track — to a .ezlproj JSON file. NOT rendered output (that's Save Laybacks). Menu
 * clicks arrive from main via onMenu; the renderer owns all the state, so it does
 * the actual serialize/restore.
 */

/**
 * Tear the current session down to an empty slate. Reuses the existing remove-track
 * and unload-video teardown so blob URLs, audio elements, and GainNodes are released
 * cleanly. Track ids are snapshotted first because removeTrack mutates the map. The
 * Music Start marker is cleared explicitly — redundant when a video was loaded
 * (unloadVideo clears it), but this is the declared empty-slate function and its
 * unload is conditional. Music Level returns to the -12 default.
 * @returns {void}
 */
function clearSession() {
  if (isPreviewing) stopPreview();
  [...trackMap.keys()].forEach(id => removeTrack(id));
  if (videoLoaded) unloadVideo();
  musicStartTime = null;
  const ml = document.getElementById('musicLevel');
  ml.value = -12;
  _lastMusicLevel = -12;
  document.getElementById('mlVal').textContent = '-12';
  clearAutomixReady();
}

/**
 * New Project — just clear. An unsaved-changes prompt is intentionally out of scope
 * for v1 (see design §11); a future "dirty" flag could warn here.
 * @returns {void}
 */
function newProject() { clearSession(); }

/**
 * Save the working session to a .ezlproj file chosen via the native dialog. Tracks
 * are walked from the DOM cards (not Map order) so on-disk track order matches what
 * the user sees; each persists its path, name, volume, trimStart, and trimEndTarget
 * (the user's intent — the effective end is derived on restore). The video block
 * saves the RAW musicStartTime, not mStart(): null = not placed, so "unset"
 * round-trips as unset instead of hardening into a number at wherever In happens to
 * sit — same discipline as trimEndTarget. trimEnd null = full extent (no out-trim).
 * @returns {Promise<void>}
 */
async function saveProject() {
  if (!window.ezl) return;
  const filePath = await window.ezl.saveProjectDialog();
  if (!filePath) return; // user cancelled the dialog

  const tracks = [];
  document.querySelectorAll('.track-card').forEach(card => {
    const id = parseInt(card.dataset.trackId);
    const t  = trackMap.get(id);
    if (!t) return;
    tracks.push({
      path:          t.filePath,
      name:          card.querySelector('.track-name')?.textContent ?? null,
      volumeDb:      parseFloat(card.querySelector('.s-trim').value),
      trimStart:     t.trimStart || 0,
      trimEndTarget: t.trimEndTarget ?? null,
    });
  });

  const activeTrack = activeTrackId !== null ? trackMap.get(activeTrackId) : null;
  const project = ProjectFile.build({
    video: (videoLoaded && currentVideoPath) ? {
      path:      currentVideoPath,
      oaEnabled,
      trimStart: videoTrimStart || 0,
      trimEnd:   videoTrimEnd,
      musicStart: musicStartTime,
    } : null,
    musicLevel:      parseInt(document.getElementById('musicLevel').value),
    activeTrackPath: activeTrack?.filePath ?? null,
    tracks,
  });

  const res = await window.ezl.writeProjectFile(filePath, project);
  if (res?.ok) showToast('Project saved');
  else showError("The project couldn't be saved.", res?.error);
}

/**
 * Open a .ezlproj file chosen via the native dialog and restore it.
 * ProjectFile.parse throws on bad version / not-a-project with a friendly message.
 * @returns {Promise<void>}
 */
async function openProject() {
  if (!window.ezl) return;
  const filePath = await window.ezl.openProjectDialog();
  if (!filePath) return; // cancelled

  const raw = await window.ezl.readProjectFile(filePath);
  if (raw?.error) { showError("The project couldn't be opened.", raw.error); return; }

  let project;
  try {
    project = ProjectFile.parse(raw);
  } catch (e) {
    showError(e.message);
    return;
  }
  await restoreProject(project);
}

/**
 * Restore a parsed project into a fresh session. The video loads first so its
 * duration is known before trims resolve: the permanent loadedmetadata listener
 * resets the video trim to full + re-fits tracks, and listeners fire in
 * registration order, so a once-listener added here (later) runs AFTER it — applying
 * the saved trim window and Music Start marker (set before the relayout, so the
 * marker lands with the handles), then re-syncing every track's Out handle (the cap
 * it computes is marker-aware). Each present track file is restored with its saved
 * volume + trim; missing files are collected. Music Level is set AFTER tracks so the
 * saved absolute per-track volumes aren't shifted by the slider's delta logic
 * (updating .value doesn't fire 'input'). The active track is re-resolved by path
 * (ids are minted fresh each session); if the saved active track is missing/absent,
 * none is active — same as a fresh load with tracks. Ends with a non-fatal
 * missing-files modal when anything couldn't be found.
 * @param {object} project - Parsed project (ProjectFile.parse output).
 * @returns {Promise<void>}
 */
async function restoreProject(project) {
  clearSession();
  const missing = [];
  const exists = async p => { try { return !!p && await window.ezl.pathExists(p); } catch { return false; } };

  if (project.video?.path) {
    if (await exists(project.video.path)) {
      const v = project.video;
      videoEl.addEventListener('loadedmetadata', () => {
        videoTrimStart = v.trimStart || 0;
        videoTrimEnd   = v.trimEnd ?? null;
        musicStartTime = v.musicStart ?? null;
        updateVideoTrimUI();
        syncAudioEndsToVideo();
      }, { once: true });
      loadVideoDescriptor(descriptorFromPath(v.path));
      setOaEnabled(v.oaEnabled !== false);
    } else {
      missing.push(project.video.path);
    }
  }

  for (const tr of project.tracks) {
    if (await exists(tr.path)) {
      addTrackDescriptor(descriptorFromPath(tr.path, tr.name), {
        volumeDb:      tr.volumeDb,
        trimStart:     tr.trimStart,
        trimEndTarget: tr.trimEndTarget,
      });
    } else {
      missing.push(tr.path);
    }
  }
  updateHeader();
  updatePreviewBtn();

  const ml = document.getElementById('musicLevel');
  ml.value = project.musicLevel;
  _lastMusicLevel = parseInt(ml.value);
  document.getElementById('mlVal').textContent = ml.value;

  if (project.activeTrackPath) {
    for (const [id, t] of trackMap) {
      if (t.filePath === project.activeTrackPath) { setActiveTrack(id); break; }
    }
  }

  if (missing.length) showMissingFiles(missing);
}

/**
 * Non-fatal "files not found" modal — the rest of the project has already loaded.
 * @param {string[]} paths - Paths that couldn't be found (basenames are shown).
 * @returns {void}
 */
function showMissingFiles(paths) {
  const modal = document.getElementById('missingModal');
  const plural = paths.length === 1 ? '' : 's';
  document.getElementById('missingMessage').textContent =
    `${paths.length} file${plural} couldn't be found and ${paths.length === 1 ? 'was' : 'were'} skipped:`;
  document.getElementById('missingList').innerHTML =
    paths.map(p => `<span class="fname">${escHtml(String(p).split(/[\\/]/).pop())}</span>`).join('');
  modal.showModal();
}
document.getElementById('btnMissingOk').addEventListener('click', () => document.getElementById('missingModal').close());
document.getElementById('btnMissingClose').addEventListener('click', () => document.getElementById('missingModal').close());

// Wire the main→renderer menu events (Save/Open/New Project).
if (window.ezl?.onMenu) {
  window.ezl.onMenu('menu:save-project', () => saveProject());
  window.ezl.onMenu('menu:open-project', () => openProject());
  window.ezl.onMenu('menu:new-project',  () => newProject());
}

/**
 * MARK: AUTH & SUBSCRIPTION — the renderer never touches tokens; it asks the main
 * process for the verified entitlement payload and gates features on its flags.
 */

/** Verified entitlement payload from main, or null when signed out. */
let entitlement = null;

/**
 * True when the current session came from the offline cache (main couldn't reach
 * the API and fell back to the verified cached entitlement). Refreshed wherever a
 * session is read: launch restore, sign-in, account-modal open.
 * @type {boolean}
 */
let sessionOffline = false;

/**
 * Shared guard for both save paths; explains itself instead of failing silently.
 * The wording per state (signed out / device cap / expired) lives in
 * device-state.js, unit-tested. Blocked saves get the expired modal — same copy and
 * Manage Account action as the launch-gate notice.
 * @returns {boolean} True when saving is allowed.
 */
function requireCanSave() {
  const blocked = DeviceState.saveBlockedMessage(entitlement);
  if (!blocked) return true;
  showExpired(blocked);
  return false;
}

const authGate     = document.getElementById('authGate');
const authError    = document.getElementById('authError');

/**
 * Show the auth gate. It fully covers the app — no dismissing, no peeking behind
 * it. Clears the checking/notice states so the sign-in form is visible (sign-out /
 * lost-session callers want the form, not the splash or a session notice).
 * @returns {void}
 */
function showAuthGate() {
  authGate.classList.remove('hidden', 'checking', 'notice');
  document.getElementById('authEmail').focus();
}

/**
 * Hide the auth gate, revealing the app.
 * @returns {void}
 */
function hideAuthGate() { authGate.classList.add('hidden'); }

/**
 * Store the verified entitlement payload and re-evaluate the Save Laybacks gate.
 * @param {object|null} payload - Verified payload, or null on sign-out.
 * @returns {void}
 */
function setEntitlement(payload) {
  entitlement = payload;
  updateHeader();
}

/**
 * Days left in the trial, or null when the status isn't trialing (or the payload
 * lacks the field).
 * @returns {number|null} Whole days remaining, floored at 0.
 */
function trialDaysLeft() {
  if (entitlement?.status !== 'trialing' || !entitlement.trial_ends_at) return null;
  return Math.max(0, Math.ceil((Date.parse(entitlement.trial_ends_at) - Date.now()) / 86400000));
}

/**
 * Days until the cached entitlement's signed valid_until — the offline grace
 * deadline. Main already refused the cache if valid_until passed, so this is only
 * ever rendered while the window is still open.
 * @returns {number|null} Whole days remaining, floored at 0, or null when unknown.
 */
function offlineDaysLeft() {
  if (!entitlement?.valid_until) return null;
  return Math.max(0, Math.ceil((Date.parse(entitlement.valid_until) - Date.now()) / 86400000));
}

/**
 * Offline-notice copy shared by the gate notice and the account-modal banner. Plain
 * language only — never mentions caches, entitlements, or signatures. "a few days"
 * covers an old cached payload without valid_until.
 * @returns {string} The message.
 */
function offlineMessage() {
  const days = offlineDaysLeft();
  const remaining = days === null ? 'a few days'
    : days === 0 ? 'less than a day'
    : `${days} day${days === 1 ? '' : 's'}`;
  return `You’re currently offline. You have ${remaining} remaining before you need to sign in.`;
}

/**
 * Trial warning, only during the final week. Silent before that (a fresh 14-day
 * trial shouldn't nag) and for every non-trial status.
 * @returns {string|null} The message, or null when no warning applies.
 */
function trialMessage() {
  const days = trialDaysLeft();
  if (days === null || days >= 7) return null;
  return days === 0 ? 'Your free trial ends today.'
       : `Your free trial ends in ${days} day${days === 1 ? '' : 's'}.`;
}

/**
 * Review-only mode explained up front, instead of a silently disabled Save button.
 * The sentence itself (trial vs subscription wording) lives in device-state.js —
 * unit-tested, and shared with the expired modal and the account banner so the copy
 * can never drift apart.
 * @returns {string|null} The message, or null when not expired.
 */
function expiredMessage() {
  if (entitlement?.status !== 'expired') return null;
  return DeviceState.saveBlockedMessage(entitlement);
}

/**
 * The one notice the current session warrants, or null. Offline outranks everything
 * (the sign-in deadline is the harder one, and stacking two countdowns on the gate
 * would bury both); expired outranks the trial countdown. The expired flag tells the
 * gate which button to offer: Continue for informational notices, Manage Account
 * for the expired one.
 * @returns {{msg: string, expired: boolean}|null} Notice descriptor, or null.
 */
function sessionNotice() {
  if (sessionOffline) return { msg: offlineMessage(), expired: false };
  const expired = expiredMessage();
  if (expired) return { msg: expired, expired: true };
  const trial = trialMessage();
  return trial ? { msg: trial, expired: false } : null;
}

/**
 * Humanize an ISO date for near-term cases so they read like speech — "renews
 * 6/13/2026" reads as a calendar date even when the date IS today. Compares
 * CALENDAR days, not 24h chunks — otherwise a target of 11pm today would show as
 * "tomorrow" from 1am, since the gap exceeds 24h. Distant dates always include the
 * year — "Jul 13" is ambiguous when the cycle could be monthly or annual.
 * @param {string} iso - ISO date string.
 * @returns {string} "today", "tomorrow", "in N days", or a formatted date.
 */
function humanDate(iso) {
  const startOfDay = t => { const d = new Date(t); d.setHours(0,0,0,0); return d.getTime(); };
  const days = Math.round((startOfDay(Date.parse(iso)) - startOfDay(Date.now())) / 86400000);
  if (days === 0) return 'today';
  if (days === 1) return 'tomorrow';
  if (days > 1 && days <= 7) return `in ${days} days`;
  return new Date(iso).toLocaleDateString(undefined,
    { month: 'short', day: 'numeric', year: 'numeric' });
}

/**
 * Plain-English plan line for the account modal (no internal jargon).
 * @returns {string} e.g. "Pro (free trial)" or "Pro".
 */
function describePlan() {
  const tier = entitlement.tier[0].toUpperCase() + entitlement.tier.slice(1);
  const days = trialDaysLeft();
  return days !== null ? `${tier} (free trial)` : tier;
}

/**
 * Plain-English status line for the account modal. cancel_at_period_end keeps
 * status 'active' but the sub won't renew, so it reads "expires" instead of
 * "renews".
 * @returns {string} The status line.
 */
function describeStatus() {
  const days = trialDaysLeft();
  const end = entitlement.current_period_end;
  switch (entitlement.status) {
    case 'trialing': return days === 0 ? 'Trial ends today' : `Trial — ${days} day${days === 1 ? '' : 's'} left`;
    case 'active':
      if (!end) return 'Active';
      return entitlement.cancel_at_period_end
        ? `Active — expires ${humanDate(end)}`
        : `Active — renews ${humanDate(end)}`;
    case 'past_due': return 'Payment problem — update your payment method';
    case 'canceled': return end ? `Canceled — works until ${humanDate(end)}` : 'Canceled';
    default:         return 'Expired — upgrade to save laybacks';
  }
}

// Sign-in form: submit to main, surface the error inline on failure, otherwise
// store the payload and drop the gate. A successful sign-in is by definition online.
document.getElementById('authForm').addEventListener('submit', async e => {
  e.preventDefault();
  const btn = document.getElementById('btnAuthSubmit');
  btn.disabled = true;
  authError.textContent = '';
  const result = await window.ezl.authLogin({
    email:    document.getElementById('authEmail').value.trim(),
    password: document.getElementById('authPassword').value,
  });
  btn.disabled = false;
  if (!result.ok) { authError.textContent = result.error; return; }
  sessionOffline = false;
  setEntitlement(result.payload);
  document.getElementById('authPassword').value = '';
  hideAuthGate();
});

// Window title = just the version, from the packaged app itself (single source of
// truth; the static <title> is a fallback for the first paint).
window.ezl.getAppInfo().then(i => { document.title = `EZLaybacks v${i.version}`; }).catch(() => {});

/*
 * Launch: silent session restore (refresh token → fresh entitlement, or the
 * verified offline cache). No session → the app asks for one. The gate goes up
 * immediately — the app must not flash while the session check runs — and
 * `checking` shows only the branded splash (no form) so a returning user doesn't
 * see the Sign In form flash before the app opens. If the session warrants a notice
 * (offline fallback, expired trial/subscription, trial ending soon), the gate
 * pauses on it instead of dismissing silently — interactive sign-ins never pause;
 * the pause is a launch-restore feature only. Expired offers Manage Account (opens
 * the portal AND enters the app), otherwise a plain Continue; both buttons are
 * reset since the gate can re-show.
 */
(async () => {
  authGate.classList.remove('hidden');
  authGate.classList.add('checking');
  const session = await window.ezl.authSession();
  sessionOffline = !!session.offline;
  if (session.loggedIn) {
    setEntitlement(session.payload); // the app behind the gate is fully armed
    const notice = sessionNotice();
    if (notice) {
      document.getElementById('authNoticeMsg').textContent = notice.msg;
      document.getElementById('btnAuthNoticeContinue').hidden = notice.expired;
      document.getElementById('btnAuthNoticeManage').hidden = !notice.expired;
      authGate.classList.remove('checking');
      authGate.classList.add('notice');
    } else { hideAuthGate(); }
  }
  else { authGate.classList.remove('checking'); } // no session → reveal the form
})();

document.getElementById('btnAuthNoticeContinue').addEventListener('click', () => {
  hideAuthGate();
});

/*
 * Expired notice's one button: open the account portal (same SSO handoff as the
 * account modal's Manage account) and enter the app — review-only mode still works,
 * so the gate shouldn't hold the user hostage meanwhile.
 */
document.getElementById('btnAuthNoticeManage').addEventListener('click', () => {
  window.ezl.openAccountPortal();
  hideAuthGate();
});

/*
 * Background entitlement refresh pushed from main (auth.js schedules one just past
 * the trial/period boundary, else every 24h). This is how a subscription that
 * lapses MID-SESSION reaches the UI without a relaunch or modal reopen. A session
 * revoked server-side (or with tokens gone) goes back to the gate, like sign-out.
 * If saving just flipped off under the user's feet, that's said once, with the same
 * modal a blocked Save click shows — unless the Save modal is up (an export may be
 * mid-batch; never stack dialogs over it — the guard on the next save attempt tells
 * them instead).
 */
window.ezl.onMenu('auth:session', (session) => {
  if (!session.loggedIn) {
    setEntitlement(null);
    sessionOffline = !!session.offline;
    showAuthGate();
    return;
  }
  const couldSave = !!entitlement?.features?.can_save;
  sessionOffline = !!session.offline;
  setEntitlement(session.payload); // updateHeader() picks up any state change
  if (couldSave && !session.payload.features?.can_save
      && !document.getElementById('saveModal').open) {
    showExpired(DeviceState.saveBlockedMessage(session.payload));
  }
});