workspace_panels_waveform_panel.js

import {
    CTX_DIM_COLOR, CTX_LIT_COLOR, CTX_DIM_PLAYED, CTX_LIT_PLAYED,
    MIN_ZOOM, MAX_ZOOM, MIN_VISIBLE_SECS, PEAKS_PER_SECOND,
    LARGE_FILE_THRESHOLD_BYTES
} from "../utilities/constants.js"
import { roundRectCorners, formatTime, formatTimeMs, hexToRgb } from "../utilities/tools.js"
import { extractPeaksFromUrl, isAudioFile } from "../utilities/audio.js"
import { ConfirmDialog } from "../components/confirm_dialog.js"
import { ACCENT_STRONG } from "../utilities/colors.js"

/**
 * Panel that wraps WaveSurfer to provide audio playback, a canvas-based region
 * lane (showing speaker segments), a minimap, a time ruler, and a zoom system.
 * Communicates back to the workspace via onRegionHover, onRegionSelect, and
 * onRegionActivate callbacks.
 */
export class WaveformPanel {

    /**
     * @param {object} workspace - the Workspace controller instance
     * @param {object} callbacks - callback functions for region interactions
     * @param {function} callbacks.onRegionHover - called with region index when a region is hovered
     * @param {function} callbacks.onRegionSelect - called with region index when a region is selected
     * @param {function} callbacks.onRegionActivate - called with region index when the playhead enters a region
     * @param {function} [callbacks.onWordActivate] - called with word index when the playhead enters a word
     */
    constructor(workspace, { onRegionHover, onRegionSelect, onRegionActivate, onWordActivate }) {
        this.workspace = workspace;
        // setup callbacks
        this.onRegionHover = onRegionHover ?? (() => {})
        this.onRegionSelect = onRegionSelect ?? (() => {})
        this.onRegionActivate = onRegionActivate ?? (() => {})
        this.onWordActivate = onWordActivate ?? (() => {})

        this.onWaveformScroll = this.onWaveformScroll.bind(this);
        this.resizeMinimap = this.resizeMinimap.bind(this);

        // ── Scroll-to-zoom on waveform ────────────────────────────────────────────
        this.zoomRafPending = false;  // debounce flag for wheel zoom rAF
        this.pendingZoom = null;   // accumulates wheel deltas between frames
        this.zoomAnchor = 'cursor'; // 'cursor' | 'transport' — what point stays fixed during zoom
        this.waveformCursorX = 0; // last known mouse X relative to waveform wrap

        this.followPlayhead = true;

        // Minimap handle resize state — for resizing the viewport by dragging the left/right edges
        this.mmHandleDragging = null; // 'left' | 'right' | null
        this.mmHandleStartX = 0;

        this._peaksInjected = false; // guard against re-entry when we reload with peaks
        this._audioLoadToken = 0;   // incremented on clearWaveformPanel to cancel stale loads

        // Minimap drag state — for panning the viewport by dragging the thumb
        this.mmDragging = false;
        this.mmDragStartX = 0;
        this.mmDragStartScroll = 0;

        // Throttle state for onTimeUpdate — coalesces rapid 'audioprocess' events into
        // one rAF callback so we don't thrash the DOM during playback.
        this.rafPending = false;
        this.pendingTime = 0;
        this.currentPlaybackRate = 1.0;

        this.pendingScrollFrac = null; // left-edge time fraction to restore after zoom re-render

        // TODO: (issue-19) Should ownership of the waveform instance be in the workspace instead of waveform panel?
        this.wavesurferInstance = null;
        this.activeStreamUrl = null;
        
        this.cachedSegments = [];

        this.#getElements();
        this.#setupListeners();
        
        // Add btn-active class to the follow button
        this.followBtn.classList.add('btn-active');

        this.clearWaveformPanel();
    }

    /** Binds all panel DOM elements to instance properties. */
    #getElements() {
        this.root = document.getElementById('waveformPanel');

        this.playBtn = this.root.querySelector('#playBtn');
        this.playDot = this.root.querySelector('#playDot');
        this.skipBack = this.root.querySelector('#skipBack');
        this.skipFwd = this.root.querySelector('#skipFwd');
        this.volumeSlider        = this.root.querySelector('#volumeSlider');
        this.volumeIconBtn       = this.root.querySelector('#volumeIconBtn');
        this.volumeCompactPopup  = this.root.querySelector('#volumeCompactPopup');
        this.volumeSliderCompact = this.root.querySelector('#volumeSliderCompact');
        this.speedSelect = this.root.querySelector('#speedSelect');
        this.sampleRate = this.root.querySelector('#sampleRate');
        this.timeRuler = this.root.querySelector('#timeRuler');
        this.gridCanvas = this.root.querySelector('#gridCanvas');

        this.followBtn = this.root.querySelector('#followBtn');

        this.minimapCanvas = this.root.querySelector('#minimapCanvas');
        this.minimapThumb  = this.root.querySelector('#minimapThumb');
        this.minimapWrap   = this.root.querySelector('#minimapWrap');

        this.waveformWrapEl = this.root.querySelector('#waveformWrap');
        this.waveformHoverLine = this.root.querySelector('#waveformHoverLine');
        this.waveformHoverLabel = this.root.querySelector('#waveformHoverLabel');
        this.transportLineLabel = this.root.querySelector('#transportLineLabel');
        this.zoomAnchorBtn = this.root.querySelector('#zoomAnchorBtn');
        this.zoomLevel = this.root.querySelector('#zoomLevel');

        this.dropZone = this.root.querySelector('#dropZone');
        this.loadingBar = this.root.querySelector('#loadingBar');
        this.loadingInfo = this.root.querySelector('#loadingInfo');
        this.playerMain = this.root.querySelector('#playerMain');

        this.fileInput = this.root.querySelector('#fileInput');
        this.deleteAudioBtn = this.root.querySelector('#deleteAudioBtn');
        this.downloadWrap = this.root.querySelector('#downloadWrap');
        this.downloadAudioBtn = this.root.querySelector('#downloadAudioBtn');
        this.downloadPopup = this.root.querySelector('#downloadPopup');
        this.downloadOriginalBtn = this.root.querySelector('#downloadOriginalBtn');
        this.downloadMp3Btn = this.root.querySelector('#downloadMp3Btn');
        this.waveform = this.root.querySelector('#waveform');
        this.zoomReset = this.root.querySelector('#zoomReset');

        this.mmHandleL = this.root.querySelector('#mmHandleL');
        this.mmHandleR = this.root.querySelector('#mmHandleR');

        this.trackName = this.root.querySelector('#trackName');
        this.currentTime = this.root.querySelector('#currentTime');
        this.totalTime = this.root.querySelector('#totalTime');

        this.statusLeft = this.root.querySelector('#statusLeft');
        this.statusRight = this.root.querySelector('#statusRight');
        this.statusText = this.root.querySelector('#statusText');

        this.regionCanvas  = this.root.querySelector('#regionCanvas');
        this.regionHitArea = this.root.querySelector('#regionHitArea');
        this.regionLane = this.root.querySelector('#regionLane');
    }

    /**
     * Reads the current waveform-related CSS custom properties.
     * @returns {{waveColor: string, progressColor: string, cursorColor: string}}
     */
    #waveColors() {
        const s = getComputedStyle(document.documentElement);
        return {
            waveColor:     s.getPropertyValue('--waveform').trim()          || '#3a3a48',
            progressColor: s.getPropertyValue('--waveform-progress').trim() || '#e8ff47',
            cursorColor:   s.getPropertyValue('--accent').trim()            || '#e8ff47',
        };
    }

    /**
     * Wires up all event listeners: minimap handle drag, minimap pan, transport
     * controls, file drop zone, wheel zoom, region lane interaction, and keyboard shortcuts.
     */
    #setupListeners() {
        // Re-colour WaveSurfer when the theme changes
        window.addEventListener('themechange', () => {
            if (this.wavesurferInstance) {
                this.wavesurferInstance.setOptions(this.#waveColors());
            }
        });

        // Window Listeners
        window.addEventListener('mousemove', (e) => {
            if (this.mmHandleDragging) {
              const scrollEl = this.getScrollEl();
              if (!scrollEl || !this.totalDuration) return;

              const W = this.minimapWrap.clientWidth;
              const dx = e.clientX - this.mmHandleStartX;
              this.mmHandleStartX = e.clientX;
              const dFrac = dx / W;

              // Use pendingScrollFrac (the intended post-correction left edge) when set,
              // because WaveSurfer leaves scrollLeft unchanged after zoom(), so reading
              // scrollLeft/scrollWidth between zoom calls gives stale fractions that cause
              // both handles to drift. rightFrac is derived from currentZoomLevel which
              // is always updated immediately.
              const leftFrac = this.pendingScrollFrac !== null
                  ? this.pendingScrollFrac
                  : scrollEl.scrollLeft / scrollEl.scrollWidth;
              const rightFrac = leftFrac + 1 / this.currentZoomLevel;

              let newLeftFrac  = leftFrac;
              let newRightFrac = rightFrac;

              if (this.mmHandleDragging === 'right') {
                newRightFrac = Math.max(leftFrac + 0.005, Math.min(1, rightFrac + dFrac));
              } else {
                newLeftFrac = Math.max(0, Math.min(rightFrac - 0.005, leftFrac + dFrac));
              }

              const newVisibleFrac = newRightFrac - newLeftFrac;
              const newZoom = Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), 1 / newVisibleFrac));

              // applyZoom uses pendingScrollFrac to suppress intermediate scroll
              // events WaveSurfer fires during zoom, then restores the correct
              // left-edge fraction (newLeftFrac pins the fixed edge for both handles).
              this.applyZoom(newZoom, newLeftFrac);
              return;
            }

            if (this.mmDragging) {
                const scrollEl = this.getScrollEl();
                if (scrollEl) {
                    const W = this.minimapWrap.clientWidth;
                    const dx = e.clientX - this.mmDragStartX;
                    const scrollDelta = (dx / W) * scrollEl.scrollWidth;
                    const maxScroll = scrollEl.scrollWidth - scrollEl.clientWidth;
                    scrollEl.scrollLeft = Math.max(0, Math.min(maxScroll, this.mmDragStartScroll + scrollDelta));
                    this.drawMinimap();
                }
            }

        });
        window.addEventListener('mouseup', () => {
            this.mmDragging = false;
            this.mmHandleDragging = null;
        });
        // Run positioning once after first paint and on resize
        window.addEventListener('resize', this.resizeMinimap);

        new ResizeObserver(([entry]) => {
            this.root.classList.toggle('controls-compact', entry.contentRect.width < 680);
        }).observe(this.root);

        // setup handle dragging
        this.setupHandleDrag(this.mmHandleL, 'left');
        this.setupHandleDrag(this.mmHandleR, 'right');

        // Zoom reset button

        // ── Controls ──────────────────────────────────────────────────────────────
        this.playBtn.addEventListener('click', () => { this.togglePlay(); });
        this.skipBack.addEventListener('click', () => { this.skipN(-5); });
        this.skipFwd.addEventListener('click', () => { this.skipN(5); });
        this.zoomReset.addEventListener('click', () => { this.resetZoom(); });

        this.volumeIconBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            this.volumeCompactPopup.classList.toggle('open');
        });
        this.volumeCompactPopup.addEventListener('click', (e) => { e.stopPropagation(); });

        this.volumeSlider.addEventListener('input', (e) => {
            const v = parseFloat(e.target.value);
            this.volumeSliderCompact.value = v;
            if (this.wavesurferInstance) this.wavesurferInstance.setVolume(v);
        });

        this.volumeSliderCompact.addEventListener('input', (e) => {
            const v = parseFloat(e.target.value);
            this.volumeSlider.value = v;
            if (this.wavesurferInstance) this.wavesurferInstance.setVolume(v);
        });

        this.speedSelect.addEventListener('change', (e) => {
            this.currentPlaybackRate = parseFloat(e.target.value);
            if (this.wavesurferInstance) {
                // set the playback rate to the dropdown value
                this.wavesurferInstance.setPlaybackRate(this.currentPlaybackRate);
            }
        });

        this.minimapThumb.addEventListener('mousedown', (e) => {
            e.preventDefault();
            e.stopPropagation();

            // start drag mode for minimap thumb
            this.mmDragging = true;
            this.mmDragStartX = e.clientX;
            this.mmDragStartScroll = this.getScrollEl()?.scrollLeft ?? 0;
        });

        // Follow playhead toggle
        this.followBtn.addEventListener('click', () => {
            // toggle following playhead
            this.followPlayhead = !this.followPlayhead;
            // toggle the btn-active style class in the follow button
            this.followBtn.classList.toggle('btn-active', this.followPlayhead);
        });

        // ── File input / drag drop ────────────────────────────────────────────────
        this.fileInput.addEventListener('change', (e) => {
            if (e.target.files[0] && !this.workspace.isReadOnly()) {
                this.#startLocalAudioLoad(e.target.files[0]);
            }
        });

        this.deleteAudioBtn.addEventListener('click', () => {
            new ConfirmDialog('Delete audio?', {
                onConfirm: async () => {
                    const project = this.activeProject;
                    const server = project?.activeServer;
                    if (server?.isConnected && project?.projectId) {
                        await server.deleteAudio(project.projectId);
                    }
                    project.hasWaveform = false;
                    project.local.waveform = null;
                    project.server.waveform = null;
                    project.waveformDirty = false;
                    this.clearWaveformPanel();
                },
            }, 'This will permanently delete the audio file from the server.');
        });

        // Download button
        this.downloadAudioBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            this.#handleDownloadClick();
        });
        this.downloadOriginalBtn.addEventListener('click', () => {
            this.downloadPopup.style.display = 'none';
            this.#triggerAudioDownload('original');
        });
        this.downloadMp3Btn.addEventListener('click', () => {
            this.downloadPopup.style.display = 'none';
            this.#triggerAudioDownload('mp3');
        });
        document.addEventListener('click', () => {
            if (this.downloadPopup) this.downloadPopup.style.display = 'none';
            if (this.volumeCompactPopup) this.volumeCompactPopup.classList.remove('open');
        });

        // Track cursor position over waveform and hit area
        this.waveformWrapEl.addEventListener('mousemove', (e) => {
            // convert mouse position to waveform position
            this.waveformCursorX = e.clientX - this.waveformWrapEl.getBoundingClientRect().left;
            this.waveformHoverLine.style.left = this.waveformCursorX + 'px';
            this.waveformHoverLine.style.display = 'block';
            this.#showHoverLabel(e.clientX);
        });
        this.waveformWrapEl.addEventListener('mouseleave', () => {
            this.waveformHoverLine.style.display = 'none';
            this.waveformHoverLabel.style.display = 'none';
        });

        // Track cursor position over the time ruler
        this.timeRuler.addEventListener('mousemove', (e) => {
            this.waveformCursorX = e.clientX - this.waveformWrapEl.getBoundingClientRect().left;
            this.waveformHoverLine.style.left = this.waveformCursorX + 'px';
            this.waveformHoverLine.style.display = 'block';
            this.#showHoverLabel(e.clientX);
        });
        this.timeRuler.addEventListener('mouseleave', () => {
            this.waveformHoverLine.style.display = 'none';
            this.waveformHoverLabel.style.display = 'none';
        });
        this.timeRuler.addEventListener('wheel', (e) => {
            e.preventDefault();
            this.waveform.dispatchEvent(
                new WheelEvent('wheel', {
                    deltaY: e.deltaY,
                    shiftKey: e.shiftKey,
                    ctrlKey: e.ctrlKey,
                    bubbles: true,
                    cancelable: true
                })
            );
        }, { passive: false });

        // Zoom anchor toggle
        this.zoomAnchorBtn.classList.toggle('btn-active', this.zoomAnchor === 'cursor');

        // toggle zoom anchor button
        this.zoomAnchorBtn.addEventListener('click', () => {
            // swap between 'transport' and 'cursor' modes
            this.zoomAnchor = this.zoomAnchor === 'cursor' ? 'transport' : 'cursor';
            this.zoomAnchorBtn.innerHTML = `<span class="icon icon-crosshair" style="width:12px;height:12px;"></span> ${this.zoomAnchor === 'cursor' ? 'CURSOR' : 'TRANSPORT'}`;
            // toggle the zoom anchor button style
            this.zoomAnchorBtn.classList.toggle('btn-active', this.zoomAnchor === 'cursor');
        });

        // handle scroll wheel on waveform
        this.waveform.addEventListener('wheel', (e) => {
            // if there is no instance or no audio, do nothing
            if (!this.wavesurferInstance || !this.totalDuration) {
                return;
            }

            e.preventDefault();

            // if shift is being held, pan instead of zooming
            if (e.shiftKey) {
                // Shift+scroll → horizontal scroll
                const scrollEl = this.getScrollEl();
                if (scrollEl) {
                    this.#scrollByDelta(scrollEl, e.deltaY);
                }
                return;
            }

            // perform zoom
            const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
            this.pendingZoom = Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), (this.pendingZoom ?? this.currentZoomLevel) * factor));

            // set the zoom level display
            this.zoomLevel.textContent =
              this.pendingZoom === 1 ? '1×' : `${this.pendingZoom.toFixed(this.pendingZoom < 10 ? 1 : 0)}×`;

            // zoom in the next animation frame
            if (!this.zoomRafPending) {
                this.zoomRafPending = true;
                requestAnimationFrame(() => {
                    if (this.pendingZoom !== null) {
                        this.doZoom(this.pendingZoom);
                        this.pendingZoom = null;
                    }
                  this.zoomRafPending = false;
                });
            }
        }, { passive: false });

        // Wheel over minimap: ctrl = zoom, plain/shift = scroll
        this.minimapWrap.addEventListener('wheel', (e) => {
            e.preventDefault();

            const scrollEl = this.getScrollEl();
            // if there is no scroll element, return
            if (!scrollEl) {
                return;
            }

            if (e.ctrlKey) {
                // Ctrl+scroll → zoom
                const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
                this.#zoomByFactor(factor);
            } else {
                // Plain or shift → scroll
                this.#scrollByDelta(scrollEl, e.deltaY);
            }
        }, { passive: false });

        // ---- FILE DROP ZONE ---- //
        // Clicking the drop zone triggers the header file input
        this.dropZone.addEventListener('click', () => {
            if (!this.workspace.isReadOnly()) this.fileInput.click();
        });

        // when dragged over, sets the style class
        this.dropZone.addEventListener('dragover', (e) => {
            if (this.workspace.isReadOnly()) return;
            e.preventDefault();
            this.dropZone.classList.add('dragover');
        });

        // when undragged over, reset the style class
        this.dropZone.addEventListener('dragleave', () => this.dropZone.classList.remove('dragover'));

        // When file is dropped into drop zone, handle the loading of it
        this.dropZone.addEventListener('drop', (e) => {
            if (this.workspace.isReadOnly()) return;
            e.preventDefault();
            this.dropZone.classList.remove('dragover');
            // load the file
            const f = e.dataTransfer.files[0];
            if (f) {
                this.#startLocalAudioLoad(f);
            }
        });

        // ---- REGIONS ---- //
        // When mouse moved over region zone
        this.regionHitArea.addEventListener('mousemove', (e) => {
            const idx = this.regionIndexAtX(e.clientX);
            this.regionHitArea.style.cursor = idx >= 0 ? 'pointer' : 'default';
            this.#hoverRegion(idx);
            // Keep the hover line and cursor X in sync with the region lane
            this.waveformCursorX = e.clientX - this.waveformWrapEl.getBoundingClientRect().left;
            this.waveformHoverLine.style.left = this.waveformCursorX + 'px';
            this.waveformHoverLine.style.display = 'block';
            this.#showHoverLabel(e.clientX);
        });

        // when mouse left region zone
        this.regionHitArea.addEventListener('mouseleave', () => {
            this.#hoverRegion(-1);
            this.waveformHoverLine.style.display = 'none';
            this.waveformHoverLabel.style.display = 'none';
        });

        // when scroll wheel on region area, transfer the event to the waveform
        this.regionHitArea.addEventListener('wheel', (e) => {
            e.preventDefault();
            this.waveform.dispatchEvent(
                new WheelEvent('wheel', {
                    deltaY: e.deltaY,
                    shiftKey: e.shiftKey,
                    ctrlKey: e.ctrlKey,
                    bubbles: true,
                    cancelable: true
                })
            );
        }, { passive: false });

        // when clicked in region area
        this._regionClickTimer = null;
        this.regionHitArea.addEventListener('click', (e) => {
            // if there is no waveform instance or audio is empty, return
            if (!this.wavesurferInstance || !this.totalDuration) {
                return;
            }

            // Defer single-click handling so a double-click can cancel it
            const time = this.clientXToTime(e.clientX);
            clearTimeout(this._regionClickTimer);
            this._regionClickTimer = setTimeout(() => {
                const timeFrac = time / this.totalDuration;
                // seek the instance to the beginning of the selected region
                this.wavesurferInstance.seekTo(Math.max(0, Math.min(1, timeFrac)));
                this.#selectRegion(this.regionIndexAtTime(time));
            }, 220);
        });

        // Double-click on region area: zoom to that region without activating edit mode
        this.regionHitArea.addEventListener('dblclick', (e) => {
            clearTimeout(this._regionClickTimer);
            if (!this.wavesurferInstance || !this.totalDuration) return;
            const time = this.clientXToTime(e.clientX);
            const idx = this.regionIndexAtTime(time);
            if (idx >= 0) {
                // Seek to the region start — triggers the 'seeking' event which scrolls
                // the transcript panel to this segment via #activateRegion
                const region = this.activeProject.transcript().segments[idx];
                const timeFrac = region.start / this.totalDuration;
                this.wavesurferInstance.seekTo(Math.max(0, Math.min(1, timeFrac)));
                this.#selectRegion(idx);
                this.zoomToRegion(idx);
            }
        });

        // when activating the context menu in the region area
        this.regionHitArea.addEventListener('contextmenu', (e) => {
            e.preventDefault();

            // select the region and open the segment context menu
            const idx = this.regionIndexAtX(e.clientX);
            if (idx >= 0) {
                this.#selectRegion(idx);
                // Anchor split popup just below the region lane at the click x position
                const laneRect = this.regionLane.getBoundingClientRect();
                const splitAnchor = { left: e.clientX, top: laneRect.bottom, bottom: laneRect.bottom };
                this.workspace.openSegmentCtxMenu(e.clientX, e.clientY, idx, splitAnchor);
            }
        });

        // waveform key shortcuts
        document.addEventListener('keydown', (e) => {
            if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
            if (e.target.isContentEditable) return;
            // Play/pause
            if (e.code === 'Space') {
                e.preventDefault();
                this.togglePlay();
            }
            // Skip 5 backward
            if (e.code === 'ArrowLeft')  { this.skipN(-5); }
            // Skip 5 forward
            if (e.code === 'ArrowRight') { this.skipN(5); }
            // Zoom In
            if (e.code === 'Equal' || e.code === 'NumpadAdd') { this.zoomIn(1.25); }
            // Zoom Out
            if (e.code === 'Minus' || e.code === 'NumpadSubtract') { this.zoomOut(1.25); }
            // Reset Zoom
            if (e.code === 'Digit0' || e.code === 'Numpad0') { this.applyZoom(1); }
        });

    }

    /** Toggles WaveSurfer playback. */
    togglePlay() {
        if (this.wavesurferInstance) {
            this.wavesurferInstance.playPause();
        }
    }

    /**
    * Seeks the playhead forward or backward by skipSeconds.
    * @param {number} skipSeconds - seconds to skip (negative = backward)
    */
    skipN(skipSeconds) {
        if (this.wavesurferInstance) {
            this.wavesurferInstance.skip(skipSeconds);
        }
    }

    /**
    * Decreases the zoom level by dividing by amount.
    * @param {number} amount - divisor (e.g. 1.25)
    */
    zoomOut(amount) {
        this.applyZoom(this.currentZoomLevel / amount);
    }

    /**
    * Increases the zoom level by multiplying by amount.
    * @param {number} amount - multiplier (e.g. 1.25)
    */
    zoomIn(amount) {
        this.applyZoom(this.currentZoomLevel * amount);
    }

    /** Resets the zoom level to 1× (fit entire track in visible width). */
    resetZoom() {
        this.applyZoom(1);
    }


    /**
    * Loads waveform data from the given project and draws regions.
    * @param {Project} project - the project to load waveform data from
    */
    loadFromProject(project) {
        this.activeProject = project;

        if (this.activeProject.hasWaveform) {
            if (!this.activeProject.localOnly) {
                this.setStatus('LOADING…');
                this.trackName.textContent = this.activeProject.projectName;
                this.loadingBar.classList.remove('done');
                this.playerMain.classList.remove('active');
                this.dropZone.classList.add('collapsed');
            }
            this.loadWaveform();
            this.drawRegions();
        }

        // In read-only mode with no audio, disable the drop zone
        if (this.workspace.isReadOnly() && !this.activeProject.hasWaveform) {
            this.dropZone.classList.add('read-only');
        }
    }

    /**
    * Initialises WaveSurfer and loads the waveform URL from the active project.
    * For server files without pre-computed peaks, checks the remote file size
    * via a HEAD request and — if the file is large — extracts peaks in chunks
    * using HTTP Range requests before handing off to WaveSurfer, preventing
    * the tab from running out of memory on long recordings.
    * Logs a warning if the waveform URL is not defined.
    */
    async loadWaveform() {
        this.dropZone.classList.add('collapsed');
        this.initWaveSurfer();

        this.trackName.textContent = this.activeProject.waveform()?.filename ?? '';

        const wf = this.activeProject.waveform();
        if (!wf?.url) {
            console.warn("Waveform URL is not defined. Cannot load waveform.");
            return;
        }

        this.activeStreamUrl = wf.url;
        let peaks    = wf.peaks    ?? undefined;
        let duration = wf.duration > 0 ? wf.duration : undefined;

        // If peaks aren't cached, check whether the remote file is large enough
        // that letting WaveSurfer decode it in full would crash the tab.
        if (!peaks) {
            let isLarge = false;
            try {
                const head = await fetch(wf.url, { method: 'HEAD' });
                const bytes = parseInt(head.headers.get('Content-Length') || '0');
                isLarge = bytes > LARGE_FILE_THRESHOLD_BYTES;
            } catch { /* network error — proceed with normal WaveSurfer decode */ }

            if (isLarge) {
                this.loadingBar.classList.add('progress');
                this.loadingBar.style.setProperty('--bar-progress', '0%');
                this.loadingInfo.classList.add('visible');
                this.loadingInfo.textContent = 'DECODING AUDIO — 0%';

                const result = await extractPeaksFromUrl(wf.url, {
                    onProgress: (pct) => {
                        const p = Math.round(pct * 100);
                        this.loadingBar.style.setProperty('--bar-progress', `${p}%`);
                        this.loadingInfo.textContent = `DECODING AUDIO — ${p}%`;
                    },
                });

                if (result) {
                    peaks    = result.peaks;
                    duration = result.duration;
                    this.detectedSampleRate = result.sampleRate;
                    // Cache so a subsequent reload doesn't re-decode
                    wf.peaks    = peaks;
                    wf.duration = duration;
                    wf.sampleRate = result.sampleRate;
                    this._peaksInjected = true; // prevent onReady from re-decoding
                    // Persist to server so future loads skip this decode
                    const peakChannel = peaks?.[0];
                    if (peakChannel && !this.activeProject.localOnly) {
                        this.activeProject.activeServer.saveWaveform(this.activeProject.projectId, {
                            peaks: Array.from(peakChannel),
                            duration,
                            sampleRate: result.sampleRate,
                        }).catch(e => console.warn('Could not save waveform peaks:', e));
                    }
                }
                // If result is null (Range not supported), fall through to normal WaveSurfer decode
            }
        }

        this.wavesurferInstance.load(this.activeStreamUrl, peaks, duration);
    }

    /** Destroys the WaveSurfer instance and resets all audio and UI state to defaults. */
    clearWaveformPanel() {
        this.activeProject = null;
        this._audioLoadToken++;

        // If the waveserver instance exists, destroy it
        if (this.wavesurferInstance) {
            try { this.wavesurferInstance.destroy(); } catch(e) {}
            this.wavesurferInstance = null;
        }

        // initialize all the class variables
        this.totalDuration = 0;
        this.isPlaying = false;
        this.playbackProgress = 0;
        this.activeStreamUrl = null;
        this.cachedWaveformPeaks = null;
        this.detectedSampleRate = null;
        this.currentZoomLevel = 1;
        this.localAudioFile      = null;

        this.trackName.textContent = '—';
        this.currentTime.textContent = '0:00.0';
        this.totalTime.textContent = '0:00';

        this.loadingBar.className = 'loading-bar done';
        this.loadingInfo.classList.remove('visible');
        this.loadingInfo.textContent = '';
        this.playerMain.classList.remove('active');
        if (this.deleteAudioBtn) this.deleteAudioBtn.style.display = 'none';
        if (this.downloadWrap) this.downloadWrap.style.display = 'none';
        this.dropZone.classList.remove('collapsed');
        this.dropZone.classList.remove('read-only');

        this.setWaveformHeight(90);

        this._peaksInjected = false; // allow onReady to extract peaks again for the next load

        // Clear the region canvas so stale regions from the previous project don't persist
        const ctx = this.regionCanvas.getContext('2d');
        ctx.clearRect(0, 0, this.regionCanvas.width, this.regionCanvas.height);

        this.regionLane.style.display = 'none';
    }

    /**
    * Updates the hovered region index and redraws the region canvas.
    * @param {number} regionIdx - segment index, or -1 to clear
    */
    setHoveredRegion(regionIdx) {
        this.drawRegions();
    }

    /**
    * Internal function to set the hovered region and call the associated callback.
    * Needed to ensure redraws happen after calling back
    * @param {number} regionIdx - segment index being hovered, or -1 to clear
    */
    #hoverRegion(regionIdx) {
        this.onRegionHover(regionIdx);
        this.drawRegions();
    }

    /**
    * Seeks the playhead to the start of the selected segment and redraws.
    * @param {number} regionIdx - segment index, or -1 to clear
    */
    setSelectedRegion(regionIdx) {
        if (regionIdx < 0) {
            return;
        }

        let region = this.activeProject.transcript().segments[regionIdx];

        if (this.wavesurferInstance && this.totalDuration) 
            this.wavesurferInstance.seekTo((region.start + 0.001) / this.totalDuration);
    }

    /**
    * Internal function to set the selected region and call the associated callback.
    * Needed to ensure redraws happen after calling back
    * @param {number} regionIdx - segment index being selected, or -1 to clear
    */
    #selectRegion(regionIdx) {
        this.onRegionSelect(regionIdx);
        this.drawRegions();
    }

    /**
    * Internal function to set the activated region and call the associated callback.
    * Needed to ensure redraws happen after calling back
    * @param {number} regionIdx - segment index being activated
    */
    #activateRegion(regionIdx) {
        this.onRegionActivate(regionIdx);
        this.drawRegions();
    }

    /**
    * Zooms the waveform so the specified segment occupies ~50% of the visible
    * width, then scrolls to center it. Uses two nested rAF calls to wait for
    * WaveSurfer to finish re-rendering at the new zoom level before scrolling.
    * @param {number} regionIdx - segment index to zoom to
    */
    zoomToRegion(regionIdx) {
        const region = this.activeProject.transcript().segments[regionIdx];
        if (!region) return;
        this.#zoomToTimeRange(region.start, region.end);
    }

    /** Zooms the waveform to the time range of the paragraph at the given index.
     * @param {number} paragraphIdx - index of the paragraph to zoom to
     */
    zoomToParagraph(paragraphIdx) {
        const paragraph = this.activeProject.transcript().paragraphs[paragraphIdx];
        if (!paragraph) return;
        const start = paragraph.segments[0].start;
        const end   = paragraph.segments[paragraph.segments.length - 1].end;
        this.#zoomToTimeRange(start, end);
    }

    /**
     * Adjusts the waveform zoom and scroll to display the given time range.
     * @param {number} start - start time in seconds
     * @param {number} end - end time in seconds
     */
    #zoomToTimeRange(start, end) {
        // If there is no wavesurfer instance, or there is no audio, do nothing
        if (!this.wavesurferInstance || !this.totalDuration) {
            return;
        }

        const duration = end - start;
        if (duration <= 0) {
            return;
        }

        // Target: range fills ~50% of visible width
        const wrapW = this.waveform.clientWidth;
        const targetPPS = wrapW / (duration * 2);
        const basePPS = this.basePxPerSec();
        const targetZoom = Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), targetPPS / basePPS));

        this.applyZoom(targetZoom);

        // Two rAF frames: first lets WaveSurfer re-render, second lets scrollWidth update
        requestAnimationFrame(() => {
            requestAnimationFrame(() => {
                const scrollEl = this.getScrollEl();
                if (!scrollEl) return;
                const midFrac = ((start + end) / 2) / this.totalDuration;
                const midPx = midFrac * scrollEl.scrollWidth;
                scrollEl.scrollLeft = Math.max(0, midPx - scrollEl.clientWidth / 2);
            });
        });
    }


    // ── WaveSurfer setup ──────────────────────────────────────────────────────

    /**
    * Computes the number of pixels per second that makes the entire track
    * fit exactly in the waveform container at zoom level 1.
    * @returns {number} pixels per second
    */
    basePxPerSec() {
        if (!this.wavesurferInstance || !this.totalDuration) return 100;
        const containerWidth = this.waveform.clientWidth;
        return containerWidth / this.totalDuration;
    }

    /**
    * Returns the effective maximum zoom level: at least MAX_ZOOM, but scaled up
    * for long files so the user can always zoom in to MIN_VISIBLE_SECS visible.
    * @returns {number}
    */
    #maxZoom() {
        if (!this.totalDuration) return MAX_ZOOM;
        return Math.max(MAX_ZOOM, this.totalDuration / MIN_VISIBLE_SECS);
    }

    /**
    * Applies a new zoom level to the WaveSurfer instance, updates the zoom
    * label, and redraws the time ruler, minimap, and regions.
    * @param {number} newZoom - desired zoom level (clamped to MIN_ZOOM..#maxZoom())
    * @param {number|null} [anchorFrac] - if provided, the scroll position will be
    *   restored to this left-edge time fraction after the zoom re-renders
    */
    applyZoom(newZoom, anchorFrac = null) {
        if (!this.wavesurferInstance || !this.totalDuration) return;
        this.currentZoomLevel = Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), newZoom));
        const pps = this.basePxPerSec() * this.currentZoomLevel;
        if (anchorFrac !== null) this.pendingScrollFrac = anchorFrac;
        this.wavesurferInstance.zoom(pps);

        // WaveSurfer 7 resets height on zoom — re-apply the stored value
        if (this.wavesurferInstance.options) {
            this.wavesurferInstance.options.height = this.waveformHeightPx;
        }
        // Add/remove class that enables horizontal scroll on the inner wave div
        this.waveform.classList.toggle('zoomed', this.currentZoomLevel > 1);
        // Format: "1×" at base zoom, one decimal < 10×, zero decimals ≥ 10×
        this.zoomLevel.textContent =
          this.currentZoomLevel === 1 ? '1×' : `${this.currentZoomLevel.toFixed(this.currentZoomLevel < 10 ? 1 : 0)}×`;
        this.updateTimeRuler();
        requestAnimationFrame(() => {
            // Apply the cursor-anchor scroll correction here. We do it in rAF
            // (rather than inside onWaveformScroll) so that ALL intermediate
            // scroll events WaveSurfer fires during zoom are ignored by
            // onWaveformScroll (which returns early while pendingScrollFrac is
            // set). Clearing pendingScrollFrac *before* setting scrollLeft
            // prevents a synchronous re-entrant scroll event from seeing it set.
            if (this.pendingScrollFrac !== null) {
                const frac = this.pendingScrollFrac;
                this.pendingScrollFrac = null;
                const scrollEl = this.getScrollEl();
                if (scrollEl) {
                    scrollEl.scrollLeft = frac * scrollEl.scrollWidth;
                }
            }
            this.drawMinimap();
            this.drawRegions();
        });
    }

    /**
    * Creates (or re-creates) the WaveSurfer instance and registers all event
    * handlers. Called both on initial file load and when switching projects.
    * The old instance must be destroyed before calling this (resetWorkspace does it).
    */
    initWaveSurfer() {
        if (this.wavesurferInstance) { this.wavesurferInstance.destroy(); }

        this.wavesurferInstance = WaveSurfer.create({
          container: '#waveform',
          ...this.#waveColors(),
          cursorWidth: 1.5,
          barWidth: 2,
          barGap: 1,
          barRadius: 1,
          height: this.waveformHeightPx,
          normalize: true,   // scale peaks relative to the loudest sample
          interact: true,    // allow clicking the waveform to seek
          pixelRatio: 1,     // use CSS pixels (not device pixels) for sharper bars at low zoom
        });

        this.wavesurferInstance.on('ready', this.onReady.bind(this));
        this.wavesurferInstance.on('audioprocess', this.onTimeUpdate.bind(this));
        this.wavesurferInstance.on('interaction', (newTime) => {
          // Fired when the user clicks the waveform to seek
          this.playbackProgress = this.totalDuration > 0 ? newTime / this.totalDuration : 0;
          this.drawMinimap();
          if(this.activeProject.hasTranscript) {
            this.drawRegions();
        }
        });
        this.wavesurferInstance.on('seeking', (currentTime) => {
          // Fired on programmatic seeks (e.g. from transcript click)
          this.playbackProgress = this.totalDuration > 0 ? currentTime / this.totalDuration : 0;
          this.currentTime.textContent = formatTimeMs(currentTime);
          this.drawMinimap();
          this.updateTransportLabel();

          if(this.activeProject.hasTranscript) {
              const idx = this.regionIndexAtTime(currentTime);
              this.#activateRegion(idx)
          }
        });
        this.wavesurferInstance.on('play', this.onPlay.bind(this));
        this.wavesurferInstance.on('pause', this.onPause.bind(this));
        this.wavesurferInstance.on('finish', this.onFinish.bind(this));
        this.wavesurferInstance.on('loading', this.onLoading.bind(this));
    }

    /**
    * WaveSurfer 'ready' handler — fires once the audio is fully decoded and ready.
    *
    * Two-pass peak extraction strategy:
    * 1. First ready (no cachedWaveformPeaks): extract peaks from the decoded buffer
    *    and reload the file with them. this enables fast zooming without re-decode.
    * 2. Second ready (_peaksInjected = true): skip extraction and finish normal setup.
    *
    * For server-streamed files, peaks weren't pre-computed before loading, so this
    * path always runs. For local drag-drop, peaks are pre-computed in the Worker
    * inside this.loadAudioFile(), so cachedWaveformPeaks is already set and the first pass
    * is skipped.
    */
    onReady() {
        this.totalDuration = this.activeProject.waveform().duration;
        // Fall back to WaveSurfer's decoded duration if the stored value is missing
        if (this.totalDuration <= 0) {
            this.totalDuration = this.wavesurferInstance.getDuration() || 0;
            this.activeProject.waveform().duration = this.totalDuration;
        }

        // If peaks weren't pre-computed (server URL load), extract them now and
        // reload with them so zoom never has to re-decode.
        if (!this.activeProject.waveform()?.peaks && !this._peaksInjected) {
          try {
            const audioBuffer = this.wavesurferInstance.getDecodedData();
            if (audioBuffer) {
              this.detectedSampleRate = audioBuffer.sampleRate;
              const totalSamples = audioBuffer.length;
              const peakCount    = Math.ceil(this.totalDuration * PEAKS_PER_SECOND);
              const channelData  = audioBuffer.getChannelData(0);
              const blockSize    = totalSamples / peakCount;
              const peaks        = new Float32Array(peakCount);
              // For each output peak, find the max absolute sample value in that block
              for (let i = 0; i < peakCount; i++) {
                let max = 0;
                const start = Math.floor(i * blockSize);
                const end   = Math.min(Math.floor(start + blockSize), totalSamples);
                for (let j = start; j < end; j++) {
                  const v = Math.abs(channelData[j]);
                  if (v > max) max = v;
                }
                peaks[i] = max;
              }
              this.activeProject.waveform().peaks = [peaks];
              this._peaksInjected = true;
              // Persist to server so future loads skip this decode
              if (!this.activeProject.localOnly) {
                  this.activeProject.activeServer.saveWaveform(this.activeProject.projectId, {
                      peaks: Array.from(peaks),
                      duration: this.totalDuration,
                      sampleRate: this.detectedSampleRate ?? this.activeProject.waveform().sampleRate,
                  }).catch(e => console.warn('Could not save waveform peaks:', e));
              }
              // Reload with peaks — fires onReady again, but _peaksInjected prevents re-entry
              this.wavesurferInstance.load(this.activeStreamUrl, this.activeProject.waveform().peaks, this.totalDuration);
              return; // wait for the second ready event to finish setup
            }
          } catch(e) {
            console.warn('Could not extract peaks:', e);
          }
        }

        // Normal ready setup (runs immediately for local files, after peak injection for server files)
        this._peaksInjected = false;
        this.loadingBar.classList.add('done');
        this.loadingBar.classList.remove('progress');
        this.loadingInfo.classList.remove('visible');
        this.loadingInfo.textContent = '';
        this.playerMain.classList.add('active');
        if (this.deleteAudioBtn && !this.workspace.isReadOnly()) this.deleteAudioBtn.style.display = '';
        if (this.downloadWrap) this.#updateDownloadButton();
        this.totalTime.textContent = formatTime(this.totalDuration);
        this.currentZoomLevel = 1;
        this.zoomLevel.textContent = '1×';
        if (this.activeProject.waveform()?.sampleRate) {
          this.sampleRate.textContent =
            `${(this.activeProject.waveform().sampleRate / 1000).toFixed(1)} kHz`;
        }
        this.updateTimeRuler();

        if (this.activeProject.hasTranscript) {
            this.drawRegions();
        }

        this.setStatus('READY', `${formatTime(this.totalDuration)} total`);
        this.attachScrollListener();
        this.resizeMinimap();
        this.drawMinimap();

    }

    /**
    * WaveSurfer 'audioprocess' handler — fires frequently during playback.
    * Coalesces updates via requestAnimationFrame to avoid redundant DOM writes.
    * Handles: timecode display, playback-progress fraction, minimap, region
    * highlight, transcript highlight, and follow-playhead auto-scroll.
    * @param {number} t - current playback time in seconds
    */
    onTimeUpdate(t) {
        this.pendingTime = t;
        if (this.rafPending) return; // already scheduled; just update the pending time
        this.rafPending = true;
        requestAnimationFrame(() => {
          this.rafPending = false;
          const time = this.pendingTime;
          this.currentTime.textContent = formatTimeMs(time);
          const pct = this.totalDuration > 0 ? ((time / this.totalDuration) * 100).toFixed(1) : 0;
          const speedLabel = this.currentPlaybackRate !== 1.0 ? `${this.currentPlaybackRate}× | ` : '';
          this.statusRight.textContent = `${speedLabel}${pct}%`;

          this.playbackProgress = this.totalDuration > 0 ? time / this.totalDuration : 0;
          this.drawMinimap();
          this.updateTransportLabel();

          // Redraw regions every frame — needed to track scroll position correctly
          const prevActive = this.workspace.activeSegmentIdx;
          if(this.activeProject.hasTranscript) {
              const regionIdx = this.regionIndexAtTime(time);
              this.#activateRegion(regionIdx);
              this.onWordActivate(time);
          }
          if (this.activeProject.hasTranscript &&
                (this.workspace.activeSegmentIdx !== prevActive || this.followPlayhead)) {
            this.drawRegions();
          }

          // Auto-scroll: keep playhead centered in the scrollable waveform
          if (this.followPlayhead && this.currentZoomLevel > 1) {
            const wrapper = this.wavesurferInstance.getWrapper ? this.wavesurferInstance.getWrapper() : null;
            const scrollEl = wrapper ? wrapper.parentElement : null;
            if (scrollEl) {
              const totalWidth = scrollEl.scrollWidth;
              const visibleWidth = scrollEl.clientWidth;
              const playheadPx = (this.totalDuration > 0 ? time / this.totalDuration : 0) * totalWidth;
              const newLeft = Math.max(0, playheadPx - visibleWidth / 2);
              // Only scroll if the playhead has drifted more than 1px to avoid jitter
              if (Math.abs(scrollEl.scrollLeft - newLeft) > 1) {
                scrollEl.scrollLeft = newLeft;
                this.drawRegions(); // redraw immediately after scroll
              }
            }
          }
        });
    }

    /** WaveSurfer 'play' handler — updates button icon and status indicators. */
    onPlay() {
        this.isPlaying = true;
        this.playBtn.textContent = '⏸';
        this.playDot.classList.remove('paused');
        this.statusText.textContent = 'PLAYING';
        this.setStatus('PLAYING');
    }

    /** WaveSurfer 'pause' handler — updates button icon and status indicators. */
    onPause() {
        this.isPlaying = false;
        this.playBtn.textContent = '▶';
        this.playDot.classList.add('paused');
        this.statusText.textContent = 'PAUSED';
        this.setStatus('PAUSED');
    }

    /** WaveSurfer 'finish' handler — playback reached the end naturally. */
    onFinish() {
        this.isPlaying = false;
        this.playBtn.textContent = '▶';
        this.playDot.classList.add('paused');
        this.statusText.textContent = 'STOPPED';
        this.setStatus('FINISHED');
    }

    /**
    * WaveSurfer 'loading' handler — fires periodically during remote audio fetch.
    * @param {number} percent - loading progress 0–100
    */
    onLoading(percent) {
        this.setStatus("LOADING", `${percent}%`);
        this.loadingBar.classList.add('progress');
        this.loadingBar.style.setProperty('--bar-progress', `${percent}%`);
        this.loadingInfo.classList.add('visible');
        this.loadingInfo.textContent = `LOADING AUDIO — ${percent}%`;
    }

    // ── Time ruler (updates with zoom) ────────────────────────────────────────

    /**
    * Rebuilds the time ruler tick marks to match the current zoom level and
    * scroll position. Tick interval is chosen from a set of "nice" values so
    * there are approximately 8 visible ticks at any zoom.
    * At very high zoom (≤10s visible) timestamps include decimal seconds.
    */
    updateTimeRuler() {
        const ruler = this.timeRuler;
        ruler.innerHTML = '';
        if (!this.totalDuration) return;

        const scrollEl = this.getScrollEl();
        // How many seconds are currently visible
        const visibleSecs = scrollEl
          ? (scrollEl.clientWidth / scrollEl.scrollWidth) * this.totalDuration
          : this.totalDuration;
        const scrollLeft = scrollEl ? scrollEl.scrollLeft : 0;
        const scrollWidth = scrollEl ? scrollEl.scrollWidth : scrollEl?.clientWidth ?? 1;
        const startT = (scrollLeft / scrollWidth) * this.totalDuration;
        const endT   = startT + visibleSecs;
        const useDecimals = visibleSecs <= 10; // show sub-second precision when highly zoomed

        // Pick a sensible step size to give ~8 ticks across the visible range
        const targetSteps = 8;
        const rawStep = visibleSecs / targetSteps;
        // Round up to the nearest "nice" interval from this table (extends to multi-hour files)
        const niceSteps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10800];
        const step = niceSteps.find(s => s >= rawStep) ?? niceSteps[niceSteps.length - 1];

        // Start at the first grid-aligned tick that is within or just before the visible range
        const firstTick = Math.ceil(startT / step) * step;

        ruler.style.position = 'relative';

        // Minimum pixel gap between label centers to prevent overlap
        const rulerWidth = ruler.clientWidth || scrollEl?.clientWidth || 800;
        const MIN_LABEL_SPACING = 75; // px
        let lastLabelX = -Infinity;

        for (let t = firstTick; t <= endT + step * 0.01; t += step) {
          if (t < 0 || t > this.totalDuration) continue;
          // Convert time → fraction of visible width, accounting for scroll
          const frac = scrollEl
            ? (t / this.totalDuration * scrollWidth - scrollLeft) / scrollEl.clientWidth
            : t / this.totalDuration;
          const labelX = frac * rulerWidth;
          if (labelX - lastLabelX < MIN_LABEL_SPACING) continue;
          lastLabelX = labelX;
          const el = document.createElement('span');
          el.textContent = useDecimals ? formatTimeMs(t) : formatTime(t);
          el.style.position = 'absolute';
          el.style.left = (frac * 100).toFixed(3) + '%';
          el.style.transform = 'translateX(-50%)'; // center label over tick
          ruler.appendChild(el);
        }
    }

    /**
    * Updates the status bar text. If `right` is omitted, only the left side changes.
    * @param {string} left - e.g. 'PLAYING', 'READY'
    * @param {string} [right] - e.g. '42.3%'
    */
    setStatus(left, right) {
        this.statusLeft.textContent = left || '';
        if (right !== undefined) this.statusRight.textContent = right;
    }

    // ── Custom canvas minimap ─────────────────────────────────────────────────

    /**
    * Redraws the minimap canvas, coloring bars by played/unplayed and
    * viewport/non-viewport states, and positions the thumb overlay div.
    */
    drawMinimap() {
        if (!this.activeProject?.waveform()?.peaks || !this.activeProject.waveform().peaks[0]) return;
        const peaks = this.activeProject.waveform().peaks[0];
        const dpr = window.devicePixelRatio || 1;
        const W = this.minimapCanvas.width  / dpr;
        const H = this.minimapCanvas.height / dpr;
        const ctx = this.minimapCanvas.getContext('2d');
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // reset transform to device pixels
        ctx.clearRect(0, 0, W, H);

        // Compute the viewport fraction (what portion of the full waveform is visible).
        // thumbRight is derived from currentZoomLevel (1/zoom) rather than from
        // scrollWidth/clientWidth, which can be temporarily out of sync during a
        // panel resize before WaveSurfer re-renders at the new container width.
        const scrollEl = this.getScrollEl();
        const thumbLeft  = scrollEl ? scrollEl.scrollLeft / scrollEl.scrollWidth : 0;
        const thumbRight = Math.min(1, thumbLeft + 1 / this.currentZoomLevel);
        const thumbPxL = Math.round(thumbLeft  * W);
        const thumbPxR = Math.round(thumbRight * W);

        // Draw one bar per pixel — bars grow upward from the bottom, filling the full canvas height
        const playX = Math.round(this.playbackProgress * W);

        for (let x = 0; x < W; x++) {
          const peakIdx = Math.floor((x / W) * peaks.length);
          const amp = Math.min((peaks[peakIdx] ?? 0) * H, H / 2);
          const inViewport = (x >= thumbPxL && x < thumbPxR);
          const played = x < playX;
          // Four visual states: played/unplayed × in-viewport/out-of-viewport
          ctx.fillStyle = played
            ? (inViewport ? CTX_LIT_PLAYED : CTX_DIM_PLAYED)
            : (inViewport ? CTX_LIT_COLOR  : CTX_DIM_COLOR);
          ctx.fillRect(x, H / 2 - amp, 1, Math.max(amp * 2, 1));
        }

        // Transport playhead line
        ctx.fillStyle = ACCENT_STRONG;
        ctx.fillRect(playX, 0, 1, H);

        // Sync the thumb overlay div position to match the viewport fraction
        this.minimapThumb.style.left  = (thumbLeft  * 100) + '%';
        this.minimapThumb.style.width = ((thumbRight - thumbLeft) * 100) + '%';
    }
    
    
    /**
    * Loads a local audio file: reads it as an ArrayBuffer, decodes it via the
    * Web Audio API to extract sample rate and multi-channel buffers, then
    * offloads peak extraction to an inline Web Worker so the UI stays responsive
    * during processing. When the worker finishes, WaveSurfer is initialised with
    * the pre-computed peaks for fast zoom.
    * @param {File} file - must have type starting with "audio/"
    */
    loadAudioFile(file) {
        if (!isAudioFile(file)) {
          alert('Please select an audio file.');
          return;
        }
    }

    /**
     * Shows the loading UI immediately, then delegates to project.loadLocalAudio
     * with a progress callback so large files report decode progress.
     * @param {File} file - the audio file to load; must have a type starting with "audio/"
     */
    #startLocalAudioLoad(file) {
        if (!isAudioFile(file)) {
            alert('Please select an audio file.');
            return;
        }

        // Check storage limit before spending time decoding (server mode only)
        const server = this.activeProject?.activeServer;
        if (server?.isConnected && server.backendUser?.subscription_tier) {
            const features = server.backendUser.subscription_tier.features || {};
            const usage    = server.backendUser.usage || {};
            const storageGb = features.storage_gb ?? null;
            if (storageGb !== null) {
                const limitBytes     = storageGb * 1024 ** 3;
                const usedBytes      = parseFloat(usage.storage_bytes || 0);
                const remainingBytes = limitBytes - usedBytes;
                if (file.size > remainingBytes) {
                    const fileMb      = (file.size / (1024 ** 2)).toFixed(0);
                    const remainingMb = Math.max(0, remainingBytes / (1024 ** 2)).toFixed(0);
                    new ConfirmDialog(
                        'Storage Limit Reached',
                        {
                            onConfirm: () => window.openAccountDrawer?.('subscription'),
                        },
                        `This file (${fileMb} MB) would exceed your ${storageGb} GB storage limit. You have ${remainingMb} MB available.\n\nYou can free up space by condensing or deleting existing projects, or upgrade your plan for more storage.`,
                        'Upgrade Plan',
                        'Dismiss',
                    );
                    return;
                }
            }
        }
        this.dropZone.classList.add('collapsed');
        this.trackName.textContent = file.name;
        this.loadingBar.classList.remove('done');
        this.playerMain.classList.remove('active');

        // Always pass a progress callback. The audio loader decides whether to
        // use chunked decoding based on decoded size, not just file size, so a
        // file can be small on disk but still go chunked. The callback lazily
        // activates the determinate bar on the first progress report; if no
        // progress is ever reported (fast in-memory path) the bar stays as an
        // indeterminate spinner.
        this.loadingBar.classList.remove('progress');
        const loadToken = this._audioLoadToken;
        this.activeProject.loadLocalAudio(file, (pct) => {
            if (this._audioLoadToken !== loadToken) return;
            const p = Math.round(pct * 100);
            this.loadingBar.classList.add('progress');
            this.loadingBar.style.setProperty('--bar-progress', `${p}%`);
            this.loadingInfo.classList.add('visible');
            this.loadingInfo.textContent = `DECODING AUDIO — ${p}%`;
        });
    }

    /**
    * Sizes the minimap canvas to the wrapper's current CSS dimensions × dpr.
    * Called on resize and after WaveSurfer reports 'ready'.
    */
    resizeMinimap() {
        const dpr = window.devicePixelRatio || 1;
        const cssW = this.minimapWrap.clientWidth || this.minimapWrap.offsetWidth;
        const cssH = parseInt(getComputedStyle(this.minimapWrap).height) || this.minimapWrap.offsetHeight;
        this.minimapCanvas.width  = cssW * dpr;
        this.minimapCanvas.height = cssH * dpr;
        this.minimapCanvas.style.width  = cssW + 'px';
        this.minimapCanvas.style.height = cssH + 'px';
        this.drawMinimap();
    }

    /**
    * Returns the scrollable container element that WaveSurfer renders into.
    * this is the parent of WaveSurfer's internal wrapper div.
    * Returns null if no WaveSurfer instance exists yet.
    * @returns {HTMLElement|null}
    */
    getScrollEl() {
        if (this.wavesurferInstance && this.wavesurferInstance.getWrapper) {
            const wrapper = this.wavesurferInstance.getWrapper();
            return wrapper ? wrapper.parentElement : null;
        }
        return null;
    }

    /**
     * Shows the white hover label at the given clientX, clamped so it stays
     * within the waveform wrap bounds.
     * @param {number} clientX - viewport X coordinate
     */
    #showHoverLabel(clientX) {
        if (!this.totalDuration) return;
        this.waveformHoverLabel.textContent = formatTimeMs(this.clientXToTime(clientX));
        this.waveformHoverLabel.style.display = 'block';
        const labelW = this.waveformHoverLabel.offsetWidth;
        const wrapW = this.waveformWrapEl.clientWidth;
        const clamped = Math.max(labelW / 2, Math.min(wrapW - labelW / 2, this.waveformCursorX));
        this.waveformHoverLabel.style.left = clamped + 'px';
    }

    /**
     * Repositions the yellow transport line label to match the current playback position.
     * Hides the label when the transport cursor is scrolled out of view.
     */
    updateTransportLabel() {
        if (!this.totalDuration || !this.wavesurferInstance) {
            this.transportLineLabel.style.display = 'none';
            return;
        }
        const scrollEl = this.getScrollEl();
        const totalWidth = scrollEl ? scrollEl.scrollWidth : this.waveformWrapEl.clientWidth;
        const scrollLeft = scrollEl ? scrollEl.scrollLeft : 0;
        const wrapWidth = this.waveformWrapEl.clientWidth;
        const x = this.playbackProgress * totalWidth - scrollLeft;
        if (x < 0 || x > wrapWidth) {
            this.transportLineLabel.style.display = 'none';
            return;
        }
        this.transportLineLabel.textContent = formatTimeMs(this.playbackProgress * this.totalDuration);
        this.transportLineLabel.style.display = 'block';
        const labelW = this.transportLineLabel.offsetWidth;
        const clamped = Math.max(labelW / 2, Math.min(wrapWidth - labelW / 2, x));
        this.transportLineLabel.style.left = clamped + 'px';
    }

    /**
    * Scroll handler attached to WaveSurfer's internal scroll container.
    * If a pendingScrollFrac is set (from applyZoom), applies the stored scroll
    * position immediately and clears it; otherwise defers redraw to the next frame.
    */
    onWaveformScroll() {
        if (this.pendingScrollFrac !== null) {
            // Zoom correction is pending — the rAF in applyZoom will apply it
            // and redraw. Suppress all scroll-driven redraws until then so that
            // intermediate WaveSurfer-controlled scroll positions never reach
            // drawMinimap (that's what causes the thumb bounce).
            return;
        }
        requestAnimationFrame(() => { this.drawMinimap(); this.updateTimeRuler(); this.drawRegions(); this.updateTransportLabel(); });
    }

    /**
    * Attaches the scroll handler to WaveSurfer's scroll container.
    * Must be called after WaveSurfer finishes loading (in onReady).
    */
    attachScrollListener() {
        const scrollEl = this.getScrollEl();
        if (scrollEl) scrollEl.addEventListener('scroll', this.onWaveformScroll, { passive: true });
    }

    /**
    * Registers a mousedown listener on a minimap edge handle that initiates a
    * resize drag (changes zoom level by adjusting the visible fraction).
    * @param {HTMLElement} el - the handle element
    * @param {'left'|'right'} side - which edge is being dragged
    */
    setupHandleDrag(el, side) {
        el.addEventListener('mousedown', (e) => {
          e.preventDefault();
          e.stopPropagation();
          this.mmHandleDragging = side;
          this.mmHandleStartX = e.clientX;
        });
    }


    /**
     * Scrolls the waveform viewport horizontally by deltaY * 2 pixels, clamped
     * to the valid scroll range.
     * @param {Element} scrollEl - the scrollable container element
     * @param {number} deltaY - raw wheel event deltaY
     */
    #scrollByDelta(scrollEl, deltaY) {
        const maxScroll = scrollEl.scrollWidth - scrollEl.clientWidth;
        scrollEl.scrollLeft = Math.max(0, Math.min(maxScroll, scrollEl.scrollLeft + deltaY * 2));
    }

    /**
     * Zooms immediately (no rAF batching) by a multiplicative factor, clamped
     * to [MIN_ZOOM, MAX_ZOOM]. Used by the minimap wheel handler.
     * @param {number} factor - multiplicative zoom factor to apply to the current zoom level
     */
    #zoomByFactor(factor) {
        this.doZoom(Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), this.currentZoomLevel * factor)));
    }

    /**
    * Zooms to `newZoom` while keeping a fixed time point under the cursor or
    * transport position. In cursor-anchor mode, computes the time fraction under
    * the mouse and restores it after zoom so that pixel stays in place.
    * @param {number} newZoom - desired zoom level
    */
    doZoom(newZoom) {
        if (!this.wavesurferInstance || !this.totalDuration) return;
        const scrollEl = this.getScrollEl();

        if (this.zoomAnchor === 'cursor' && scrollEl) {
          // Capture the time fraction under the cursor before zoom changes scrollWidth
          const oldScrollW = scrollEl.scrollWidth;
          const cursorTimeFrac = (scrollEl.scrollLeft + this.waveformCursorX) / oldScrollW;

          // Pre-compute the scroll correction as an anchorFrac so that the
          // pendingScrollFrac mechanism corrects scrollLeft at the first scroll
          // event (before any rAF draw fires).  This prevents the minimap thumb
          // from briefly jumping to the wrong position before snapping back.
          const clampedZoom = Math.max(MIN_ZOOM, Math.min(this.#maxZoom(), newZoom));
          const estNewScrollW = oldScrollW * clampedZoom / this.currentZoomLevel;
          const anchorFrac = Math.max(0, cursorTimeFrac - this.waveformCursorX / estNewScrollW);

          this.applyZoom(clampedZoom, anchorFrac);
        } else {
          this.applyZoom(newZoom); // transport anchor — WaveSurfer keeps playhead centered
        }
    }

    /**
    * Sets the waveform panel height (clamped to 40–400px), updates the CSS
    * variable, and redraws the minimap and regions.
    * @param {number} h - desired height in pixels
    */
    setWaveformHeight(h) {
        h = Math.max(40, Math.min(400, h));
        this.waveformHeightPx = h;
        document.documentElement.style.setProperty('--waveform-h', h + 'px');
        if (this.wavesurferInstance) this.wavesurferInstance.setOptions({ height: h });
        requestAnimationFrame(() => { this.drawMinimap(); this.drawRegions(); });
    }

    /**
     * Entry point for loading an audio file into the player. Validates the file,
     * updates all relevant UI and AppState, then delegates the actual data work to
     * loadAudioFile. Once loading completes, initialises WaveSurfer with the
     * pre-computed peaks for fast zoom.
     * @param {File} file - must have type starting with "audio/"
     */
    async initAudio(file) {
        if (!isAudioFile(file)) {
            alert('Please select an audio file.');
            return;
        }

        this.dropZone.classList.add('collapsed');
        this.trackName.textContent = file.name;
        this.loadingBar.classList.remove('done');
        this.playerMain.classList.remove('active');
        this.setStatus('LOADING…');

        this.detectedSampleRate = null;
        if (this.activeProject.waveform()) this.activeProject.waveform().peaks = null;

        const { sampleRate, peaks, duration } = await loadAudioFile(file, {
            onProgress: (pct) => {
                const p = Math.round(pct * 100);
                this.loadingBar.classList.add('progress');
                this.loadingBar.style.setProperty('--bar-progress', `${p}%`);
                this.loadingInfo.classList.add('visible');
                this.loadingInfo.textContent = `DECODING AUDIO — ${p}%`;
            },
        });

        this.detectedSampleRate = sampleRate;
        if (this.activeProject.waveform()) this.activeProject.waveform().peaks = peaks;

        this.initWaveSurfer();
        const audioUrl = URL.createObjectURL(file);
        this.activeStreamUrl = audioUrl;
        this.wavesurferInstance.load(audioUrl, peaks, duration);
    }

    /**
     * Shows or hides the download button and configures the original-format label.
     * Called each time audio becomes ready.
     */
    #updateDownloadButton() {
        const wf = this.activeProject?.waveform();
        if (!wf) {
            this.downloadWrap.style.display = 'none';
            return;
        }
        this.downloadWrap.style.display = '';

        // Determine the original format from the filename (e.g. "audio.wav" → "wav")
        const ext = (wf.filename || '').split('.').pop().toLowerCase();
        const isLocalOnly = this.activeProject.localOnly;
        const hasMp3 = !isLocalOnly && wf.hasAudioMp3;
        const originalIsMp3 = ext === 'mp3';

        // Show popup options only when there are two distinct formats to choose from
        const showPopup = hasMp3 && !originalIsMp3;
        this.downloadOriginalBtn.textContent = ext ? ext.toUpperCase() : 'Original';
        this.downloadMp3Btn.style.display = showPopup ? '' : 'none';
        this.downloadOriginalBtn.style.display = showPopup ? '' : 'none';

        // Store state for click handler
        this._downloadShowPopup = showPopup;
    }

    /**
     * Handles a click on the main download button.
     * Shows a format picker popup when both original and MP3 are available,
     * otherwise triggers an immediate download of the only available format.
     */
    #handleDownloadClick() {
        if (this._downloadShowPopup) {
            const isVisible = this.downloadPopup.style.display !== 'none';
            this.downloadPopup.style.display = isVisible ? 'none' : '';
        } else {
            this.#triggerAudioDownload('original');
        }
    }

    /**
     * Triggers a browser download for the audio file.
     * @param {'original'|'mp3'} format - Which format to download.
     */
    #triggerAudioDownload(format) {
        const wf = this.activeProject?.waveform();
        if (!wf) return;

        const isLocalOnly = this.activeProject.localOnly;
        let url, filename;

        if (isLocalOnly) {
            // Local project — use the blob URL directly
            url = wf.url;
            filename = wf.filename || 'audio';
        } else {
            const server = this.activeProject.activeServer;
            const projectId = this.activeProject.projectId;
            const baseUrl = `${server.audioUrl(projectId)}&download=1`;
            if (format === 'mp3') {
                url = `${baseUrl}&format=mp3`;
                filename = 'audio.mp3';
            } else {
                url = baseUrl;
                filename = wf.filename || 'audio';
            }
        }

        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
    }


    /**
    * Redraws the region lane canvas from scratch using the cached render runs.
    * Called on every frame during playback and on zoom/scroll/segment edits.
    *
    * Visual design:
    * - Segments are grouped into "runs" (consecutive same-speaker segments with
    *   no large gap) and rendered as color bars that grow upward from the bottom.
    * - Normal height ≈ 70% of lane; hovered ≈ 88%; selected/active = 100%.
    * - Only the first and last segment of a run get rounded outer corners.
    * - A speaker name label is drawn left-anchored inside each visible run.
    * - If a split popup is open, a dashed vertical line shows the split preview.
    */
    drawRegions() {
        if (!this.activeProject) {
            return;
        }

        // TODO: (issue-21) Don't redraw all on region style change
        this.regionLane.style.display = this.activeProject.hasTranscript ? '' : 'none';
        if (!this.activeProject.hasWaveform || !this.activeProject.hasTranscript || this.totalDuration <= 0) {
            return;
        }

        const scrollEl = this.getScrollEl();
        const laneEl   = this.regionLane;
        const dpr = window.devicePixelRatio || 1;
        const W = laneEl.clientWidth;
        const H = laneEl.clientHeight;

        // Size canvas backing store to physical pixels for crisp rendering
        this.regionCanvas.width  = W * dpr;
        this.regionCanvas.height = H * dpr;
        this.regionCanvas.style.width  = W + 'px';
        this.regionCanvas.style.height = H + 'px';

        const ctx = this.regionCanvas.getContext('2d');
        ctx.scale(dpr, dpr);
        ctx.clearRect(0, 0, W, H);

        if (!this.activeProject.transcript().segments.length) {
            console.warn("No cached segments.")
            return;
        }

        // totalW is the full scrollable width of the waveform; scrollX is current offset
        const totalW   = scrollEl ? scrollEl.scrollWidth : W;
        const scrollX  = scrollEl ? scrollEl.scrollLeft  : 0;
        const currentT = this.totalDuration * this.playbackProgress;
        const MIN_W    = 3;  // minimum pixel width for any segment bar
        const GAP      = 1;  // gap in pixels between the last segment and the next paragraph

        const searchActive = this.workspace.searchMatchSet !== null;

        // Heights: normal, hovered, selected/active — all bottom-anchored
        const H_NORMAL = Math.round(H * 0.70);
        const H_HOVER  = Math.round(H * 0.88);
        const H_BIG    = H;
        const RAD      = 4;             // fixed pixel radius — never changes
        const LABEL_Y  = H - H_NORMAL / 2; // label stays at center of the bottom portion

        const _paragraphOp = (paragraph) => {
            let speakerHue = this.activeProject.getSpeaker(paragraph.speaker).hue;
            if (!speakerHue) {
                console.warn("Speaker has no hue.")
                speakerHue = "#FFFFFF";
            }

            const { r, g, b } = hexToRgb(speakerHue);
            const numSegs = paragraph.segments.length;

            const paragraphFirstSeg = paragraph.segments[0];
            const paragraphLastSeg  = paragraph.segments[numSegs - 1];
            // Pixel extents of the entire paragraph in scroll-space
            const paragraphX0 = (paragraphFirstSeg.start / this.totalDuration) * totalW - scrollX;
            const paragraphX1 = (paragraphLastSeg.end    / this.totalDuration) * totalW - scrollX;
            if (paragraphX1 < 0 || paragraphX0 > W) return; // paragraph completely off-screen — skip

            const firstIndex = paragraphFirstSeg.index;

            const _segOp = (segment, pos) => {
                const segIndex = this.activeProject.transcript().segments.indexOf(segment);
                const isFirst    = pos === 0;
                const isLast     = pos === numSegs - 1;
                const isSelected = segIndex === this.workspace.selectedSegmentIdx;
                const isActive   = currentT >= segment.start && currentT < segment.end;
                // A segment is hovered if the mouse is over its transcript span OR if the user
                // is hovering the speaker row in the speakers panel (hoveredSpeakerId)
                const hoveredPara = this.workspace.hoveredParagraphIdx >= 0
                    ? this.activeProject.transcript().paragraphs[this.workspace.hoveredParagraphIdx]
                    : null;
                const isHovered = segIndex === this.workspace.hoveredSegmentIdx ||
                                   segment.speaker === this.workspace.hoveredSpeakerId ||
                                   hoveredPara?.segments.includes(segment);

                const nextSeg  = !isLast ? paragraph.segments[pos + 1] : null;
                const rawStart = (segment.start / this.totalDuration) * totalW - scrollX;
                // Right edge extends to the start of the next segment (fills any tiny gap between
                // segments in the same paragraph) unless this is the last segment of the paragraph
                const rawEnd   = nextSeg
                  ? (nextSeg.start / this.totalDuration) * totalW - scrollX
                  : (segment.end       / this.totalDuration) * totalW - scrollX;

                if (rawEnd < 0 || rawStart > W) {
                    return; // segment off-screen
                }

                const xStart = rawStart;
                const xEnd   = Math.max(rawEnd, rawStart + MIN_W);
                const x = Math.max(0, xStart);
                const w = Math.min(W, xEnd) - x - (isLast ? GAP : 0);
                if (w < 0.5) {
                    return;
                }

                // Height and vertical position (bottom-anchored so they grow upward)
                const ph = isSelected || isActive ? H_BIG : isHovered ? H_HOVER : H_NORMAL;
                const py = H - ph;

                // Only the first/last segment of a paragraph gets rounded outer corners
                const tl = isFirst ? RAD : 0;
                const bl = isFirst ? RAD : 0;
                const tr = isLast  ? RAD : 0;
                const br = isLast  ? RAD : 0;

                const alpha = isSelected ? 1.0 : isActive ? 0.95 : isHovered ? 0.85 : 0.72;
                const isSearchMatch = !searchActive || this.workspace.searchMatchSet.has(segIndex);
                ctx.fillStyle = isSearchMatch
                    ? `rgba(${r},${g},${b},${alpha})`
                    : `rgba(80,80,80,${alpha * 0.4})`; /* --muted equivalent, alpha computed */
                roundRectCorners(ctx, x, py, w, ph, [tl, tr, br, bl]);
                ctx.fill();

                // Accent yellow outline on selected segment
                if (isSelected) {
                    ctx.save();
                    ctx.strokeStyle = ACCENT_STRONG;
                    ctx.lineWidth = 1.5;
                    // Inset by 0.75px so the stroke doesn't bleed outside the fill area
                    roundRectCorners(ctx, x + 0.75, py + 0.75, w - 1.5, ph - 1.5, [tl, tr, br, bl]);
                    ctx.stroke();
                    ctx.restore();
                }
            }

            paragraph.segments.forEach(_segOp);

            // Speaker label — left-anchored to visible paragraph start, clipped to visible paragraph width
            const visRunX0 = Math.max(0, paragraphX0);
            const visRunX1 = Math.min(W, paragraphX1);
            const visRunW  = visRunX1 - visRunX0;
            if (visRunW >= 28) { // skip label if paragraph is too narrow to show any text
                const name = this.activeProject.getSpeaker(paragraph.speaker).name;
                ctx.font = `500 8px "IBM Plex Mono", monospace`;
                const textW = ctx.measureText(name).width;
                if (textW + 6 <= visRunW) {
                    const isRunActive  = paragraph.segments.some((seg) => currentT >= seg.start && currentT < seg.end);
                    const isRunHovered = paragraph.segments.some((seg) => seg.idx === this.workspace.hoveredSegmentIdx || seg.speaker === this.workspace.hoveredSpeakerId) ||
                                        this.workspace.hoveredParagraphIdx >= 0 && this.activeProject.transcript().paragraphs[this.workspace.hoveredParagraphIdx] === paragraph;
                    const isRunSel     = paragraph.segments.some((seg) => seg.idx === this.workspace.selectedSegmentIdx);
                    const allSegs = this.activeProject.transcript().segments;
                    const isRunSearchMatch = !searchActive || paragraph.segments.some(seg => this.workspace.searchMatchSet.has(allSegs.indexOf(seg)));
                    ctx.save();
                    // Clip text to the visible portion of the paragraph so it doesn't bleed into adjacent paragraphs
                    ctx.beginPath();
                    ctx.rect(visRunX0, 0, visRunW, H);
                    ctx.clip();
                    ctx.fillStyle = `rgba(255,255,255,${isRunSearchMatch ? ((isRunActive || isRunHovered || isRunSel) ? 0.95 : 0.8) : 0.25})`; /* --text equivalent, alpha computed */
                    ctx.textAlign = 'left';
                    ctx.textBaseline = 'middle';
                    // Anchor to left edge of visible paragraph (or paragraph start if on-screen), with padding
                    const labelX = Math.max(visRunX0, paragraphX0) + 5;
                    ctx.fillText(name, labelX, LABEL_Y);
                    ctx.restore();
                }
            }
        }

        this.activeProject.transcript().paragraphs.forEach(_paragraphOp);

        // Split preview line — dashed yellow vertical line at the current split point
        if (this.workspace.splitPopup) {
            const seg = this.activeProject.transcript().segments[this.workspace.splitPopup.previewFrac.segIdx];
            if (seg) {
                const splitT = seg.start + this.workspace.splitPopup.previewFrac.timeFrac * (seg.end - seg.start);
                const splitX = (splitT / this.totalDuration) * totalW - scrollX;
                if (splitX >= 0 && splitX <= W) {
                    ctx.save();
                    ctx.strokeStyle = ACCENT_STRONG;
                    ctx.lineWidth = 1.5;
                    ctx.setLineDash([3, 3]);
                    ctx.beginPath();
                    ctx.moveTo(splitX, 0);
                    ctx.lineTo(splitX, H);
                    ctx.stroke();
                    ctx.restore();
                }
            }
        }
    }

    /**
    * Converts a time (seconds) to a clientX coordinate over the region lane.
    * @param {number} time - seconds
    * @returns {number} clientX in viewport pixels
    */
    timeToClientX(time) {
        const scrollEl = this.getScrollEl();
        const laneEl = this.regionLane;
        const rect = laneEl.getBoundingClientRect();
        const totalW = scrollEl ? scrollEl.scrollWidth : rect.width;
        const scrollX = scrollEl ? scrollEl.scrollLeft : 0;

        const mouseX = (time / this.totalDuration) * totalW - scrollX;
        const clientX = mouseX + rect.left;

        return clientX;
    }

    /**
    * Converts a clientX coordinate over the region lane to a time in seconds.
    * @param {number} clientX - viewport X coordinate
    * @returns {number} time in seconds
    */
    clientXToTime(clientX) {
        const scrollEl = this.getScrollEl();
        const laneEl = this.regionLane;
        const rect = laneEl.getBoundingClientRect();
        const totalW = scrollEl ? scrollEl.scrollWidth : rect.width;
        const scrollX = scrollEl ? scrollEl.scrollLeft : 0;
        const mouseX = clientX - rect.left;
        const time = ((mouseX + scrollX) / totalW) * this.totalDuration;

        return time;
    }

    /**
    * Given a mouse clientX coordinate over the region lane, returns the index
    * of the transcript segment whose time range covers that position.
    * @param {number} clientX - mouse X in viewport coordinates
    * @returns {number} segment index, or -1 if no segment at that position
    */
    regionIndexAtX(clientX) {
        const time = this.clientXToTime(clientX);
        return this.regionIndexAtTime(time);
//
//        if (!this.activeProject.transcript().segments.length || !this.totalDuration) return -1;
//        const scrollEl = this.getScrollEl();
//        const laneEl = this.regionLane;
//        const rect = laneEl.getBoundingClientRect();
//        const totalW = scrollEl ? scrollEl.scrollWidth : rect.width;
//        const scrollX = scrollEl ? scrollEl.scrollLeft : 0;
//        const mouseX = clientX - rect.left;
//
//        for (let i = 0; i < this.activeProject.transcript().segments.length; i++) {
//            const seg = this.activeProject.transcript().segments[i];
//            const xStart = (seg.start / this.totalDuration) * totalW - scrollX;
//            const xEnd   = (seg.end   / this.totalDuration) * totalW - scrollX;
//            if (mouseX >= xStart && mouseX < xEnd) return i;
//        }
//        return -1;
    }

    /**
    * Returns the index of the region at the given time
    * @param {number} time - time in seconds to look up
    * @returns {number} the segment index at the given time, or -1 if none
    */
    regionIndexAtTime(time) {
        const segAtTime = this.activeProject.segmentAtTime(time);
        return segAtTime ?? -1;
    }
}