presentation.js

import { Project, Waveform, Transcript } from './project.js';
import { roundRectCorners, formatTime, formatTimeMs, hexToRgb } from './utilities/tools.js';

// ── PresentationWaveform ──────────────────────────────────────────────────────
// Minimal audio player for presentation mode.
// Builds its own DOM; no drop zone, no minimap. Defaults to 20 s visible zoom.

/**
 * Minimal audio player with region lane for presentation (read-only) mode.
 */
class PresentationWaveform {
    /**
     * @param {HTMLElement} mountEl - Container element to render the player into.
     * @param {PresentationController} ctrl - Shared controller for hover/select state.
     * @param {object} callbacks - Event callbacks.
     * @param {Function} callbacks.onRegionHover - Called with segment index when hovering a region.
     * @param {Function} callbacks.onRegionSelect - Called with segment index when a region is clicked.
     * @param {Function} callbacks.onRegionActivate - Called with segment index when playhead enters a region.
     */
    constructor(mountEl, ctrl, { onRegionHover, onRegionSelect, onRegionActivate }) {
        this.mountEl          = mountEl;
        this.ctrl             = ctrl;
        this.onRegionHover    = onRegionHover    ?? (() => {});
        this.onRegionSelect   = onRegionSelect   ?? (() => {});
        this.onRegionActivate = onRegionActivate ?? (() => {});

        this.wavesurferInstance = null;
        this.activeProject      = null;
        this.totalDuration      = 0;
        this.playbackProgress   = 0;
        this._rafPending        = false;
        this._pendingTime       = 0;
        this._peaksInjected     = false;
        this._audioUrl          = null;
        this._muted             = false;
        this._volBeforeMute     = 0.8;

        this.#buildDOM();
        this.#setupControls();
        this.#setupRegionHit();
    }

    /**
     * Creates and inserts the player HTML structure and caches element references.
     */
    #buildDOM() {
        this.mountEl.innerHTML = `
            <div class="pw-player">
                <div class="pw-waveform-area">
                    <div class="pw-waveform"></div>
                    <div class="pw-region-lane">
                        <canvas class="pw-region-canvas"></canvas>
                        <div class="pw-region-hit"></div>
                    </div>
                </div>
                <div class="pw-controls">
                    <button class="pw-play-btn" title="Play / Pause">&#9654;</button>
                    <span class="pw-timecode">
                        <span class="pw-current">0:00.0</span><br><span class="pw-total">0:00</span>
                    </span>
                    <div class="pw-spacer-start"></div>
                    <div class="pw-nav-group">
                        <button class="pw-nav" data-nav="prev-para" title="Previous paragraph" disabled>&#171;</button>
                        <button class="pw-nav" data-nav="prev-seg"  title="Previous segment"   disabled>&#8249;</button>
                        <button class="pw-nav" data-nav="next-seg"  title="Next segment"        disabled>&#8250;</button>
                        <button class="pw-nav" data-nav="next-para" title="Next paragraph"      disabled>&#187;</button>
                    </div>
                    <div class="pw-spacer"></div>
                    <label class="pw-vol-inline" title="Volume">
                        <span class="pw-vol-label">VOL</span>
                        <input type="range" class="pw-volume" min="0" max="1" step="0.01" value="0.8"/>
                    </label>
                    <div class="pw-vol-mobile">
                        <button class="pw-vol-icon-btn" title="Volume">&#128266;</button>
                        <div class="pw-vol-popup" hidden>
                            <button class="pw-mute-btn" title="Mute / Unmute">&#128266;</button>
                            <input type="range" class="pw-vol-popup-range" min="0" max="1" step="0.01" value="0.8"/>
                        </div>
                    </div>
                    <select class="pw-speed" title="Playback speed">
                        <option value="0.5">0.5×</option>
                        <option value="0.75">0.75×</option>
                        <option value="1" selected>1.0×</option>
                        <option value="1.25">1.25×</option>
                        <option value="1.5">1.5×</option>
                        <option value="2">2.0×</option>
                    </select>
                </div>
            </div>`;

        const m = this.mountEl;
        this.waveformEl    = m.querySelector('.pw-waveform');
        this.regionLane    = m.querySelector('.pw-region-lane');
        this.regionCanvas  = m.querySelector('.pw-region-canvas');
        this.regionHit     = m.querySelector('.pw-region-hit');
        this.playBtn       = m.querySelector('.pw-play-btn');
        this.currentEl     = m.querySelector('.pw-current');
        this.totalEl       = m.querySelector('.pw-total');
        this.volumeSlider  = m.querySelector('.pw-volume');
        this.speedSelect   = m.querySelector('.pw-speed');
        this.volIconBtn    = m.querySelector('.pw-vol-icon-btn');
        this.volPopup      = m.querySelector('.pw-vol-popup');
        this.muteBtn       = m.querySelector('.pw-mute-btn');
        this.volPopupRange = m.querySelector('.pw-vol-popup-range');
        this.navBtns = {
            prevPara: m.querySelector('[data-nav="prev-para"]'),
            prevSeg:  m.querySelector('[data-nav="prev-seg"]'),
            nextSeg:  m.querySelector('[data-nav="next-seg"]'),
            nextPara: m.querySelector('[data-nav="next-para"]'),
        };
    }

    /**
     * Attaches event listeners to all player controls (play, volume, speed, navigation).
     */
    #setupControls() {
        this.playBtn.addEventListener('click', () => this.wavesurferInstance?.playPause());

        // Navigation
        this.navBtns.prevPara.addEventListener('click', () => this.#navPrevPara());
        this.navBtns.prevSeg.addEventListener('click',  () => this.#navPrevSeg());
        this.navBtns.nextSeg.addEventListener('click',  () => this.#navNextSeg());
        this.navBtns.nextPara.addEventListener('click', () => this.#navNextPara());

        // Inline volume (desktop)
        this.volumeSlider.addEventListener('input', () => {
            const v = parseFloat(this.volumeSlider.value);
            this.wavesurferInstance?.setVolume(v);
            this.volPopupRange.value = v;
            this.#updateVolIcon(v);
        });

        // Mobile volume popup toggle
        this.volIconBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            this.volPopup.hidden = !this.volPopup.hidden;
        });
        document.addEventListener('click', (e) => {
            if (!this.volPopup.hidden && !this.volPopup.contains(e.target) && e.target !== this.volIconBtn) {
                this.volPopup.hidden = true;
            }
        });
        this.volPopupRange.addEventListener('input', () => {
            const v = parseFloat(this.volPopupRange.value);
            this.wavesurferInstance?.setVolume(v);
            this.volumeSlider.value = v;
            this._muted = false;
            this.#updateVolIcon(v);
        });
        this.muteBtn.addEventListener('click', () => {
            if (this._muted) {
                this._muted = false;
                this.wavesurferInstance?.setVolume(this._volBeforeMute);
                this.volumeSlider.value  = this._volBeforeMute;
                this.volPopupRange.value = this._volBeforeMute;
                this.#updateVolIcon(this._volBeforeMute);
            } else {
                this._volBeforeMute = parseFloat(this.volumeSlider.value);
                this._muted = true;
                this.wavesurferInstance?.setVolume(0);
                this.#updateVolIcon(0);
            }
        });

        this.speedSelect.addEventListener('change', () => {
            this.wavesurferInstance?.setPlaybackRate(parseFloat(this.speedSelect.value));
        });
    }

    /**
     * Updates the volume icon buttons to reflect the current volume level or mute state.
     * @param {number} vol - Current volume level (0–1).
     */
    #updateVolIcon(vol) {
        const icon = (vol === 0 || this._muted) ? '&#128263;' : vol < 0.4 ? '&#128264;' : '&#128266;';
        this.volIconBtn.innerHTML = icon;
        this.muteBtn.innerHTML   = (vol === 0 || this._muted) ? '&#128263;' : '&#128266;';
    }

    /**
     * Attaches click and mousemove listeners to the invisible hit region overlay
     * for translating pointer position to segment hover/select events.
     */
    #setupRegionHit() {
        const hitTime = (e) => {
            const rect    = this.regionHit.getBoundingClientRect();
            const scrollEl = this.#getScrollEl();
            const totalW  = scrollEl ? scrollEl.scrollWidth : rect.width;
            const scrollX = scrollEl ? scrollEl.scrollLeft  : 0;
            return ((e.clientX - rect.left + scrollX) / totalW) * this.totalDuration;
        };
        this.regionHit.addEventListener('click', (e) => {
            if (!this.activeProject?.hasTranscript || !this.totalDuration) return;
            const idx = this.#regionAtTime(hitTime(e));
            if (idx >= 0) this.onRegionSelect(idx);
        });
        this.regionHit.addEventListener('mousemove', (e) => {
            if (!this.activeProject?.hasTranscript || !this.totalDuration) return;
            this.onRegionHover(this.#regionAtTime(hitTime(e)));
        });
        this.regionHit.addEventListener('mouseleave', () => this.onRegionHover(-1));
    }

    /**
     * Initialises WaveSurfer and loads audio from the given project.
     * @param {Project} project - The project whose waveform should be loaded.
     */
    loadFromProject(project) {
        this.activeProject  = project;
        this._peaksInjected = false;
        if (!project.hasWaveform) return;
        this.#initWaveSurfer();
        const wf = project.waveform();
        this._audioUrl = wf.url;
        this.wavesurferInstance.load(wf.url, wf.peaks ?? null, wf.duration > 0 ? wf.duration : undefined);
    }

    /**
     * Creates (or re-creates) the WaveSurfer instance and wires up its event handlers.
     */
    #initWaveSurfer() {
        if (this.wavesurferInstance) this.wavesurferInstance.destroy();

        this.wavesurferInstance = WaveSurfer.create({
            container:   this.waveformEl,
            waveColor:   '#b8b8cc',
            progressColor: '#525600',
            cursorColor: '#525600',
            cursorWidth: 1.5,
            barWidth:    2,
            barGap:      1,
            barRadius:   1,
            height:      90,
            normalize:   true,
            interact:    true,
            pixelRatio:  1,
        });

        this.wavesurferInstance.on('ready', () => this.#onReady());
        this.wavesurferInstance.on('audioprocess', t => this.#onTimeUpdate(t));
        this.wavesurferInstance.on('interaction', t => {
            this.playbackProgress = this.totalDuration > 0 ? t / this.totalDuration : 0;
            this.drawRegions();
            if (this.activeProject?.hasTranscript) this.onRegionActivate(this.#regionAtTime(t));
            this.#updateNavButtons();
        });
        this.wavesurferInstance.on('seeking', t => {
            this.playbackProgress = this.totalDuration > 0 ? t / this.totalDuration : 0;
            this.currentEl.textContent = formatTimeMs(t);
            this.drawRegions();
            if (this.activeProject?.hasTranscript) this.onRegionActivate(this.#regionAtTime(t));
            this.#updateNavButtons();
        });
        this.wavesurferInstance.on('play',   () => { this.playBtn.innerHTML = '&#9208;'; });
        this.wavesurferInstance.on('pause',  () => { this.playBtn.innerHTML = '&#9654;'; });
        this.wavesurferInstance.on('finish', () => { this.playBtn.innerHTML = '&#9654;'; this.playbackProgress = 0; });
    }

    /**
     * Handles the WaveSurfer 'ready' event: resolves duration, extracts peaks if
     * needed, sets initial zoom, and wires the scroll listener for region sync.
     */
    #onReady() {
        this.totalDuration = this.activeProject.waveform().duration;
        if (this.totalDuration <= 0) {
            this.totalDuration = this.wavesurferInstance.getDuration() || 0;
            this.activeProject.waveform().duration = this.totalDuration;
        }

        // Extract peaks from decoded audio if not already available, then reload
        if (!this.activeProject.waveform()?.peaks && !this._peaksInjected) {
            try {
                const buf = this.wavesurferInstance.getDecodedData();
                if (buf) {
                    const totalSamples = buf.length;
                    const peakCount    = Math.ceil(this.totalDuration * 140);
                    const channelData  = buf.getChannelData(0);
                    const blockSize    = totalSamples / peakCount;
                    const peaks        = new Float32Array(peakCount);
                    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;
                    this.activeProject.activeServer?.saveWaveform?.(
                        this.activeProject.projectId,
                        { peaks: Array.from(peaks), duration: this.totalDuration, sampleRate: buf.sampleRate }
                    ).catch(() => {});
                    this.wavesurferInstance.load(this._audioUrl, [peaks], this.totalDuration);
                    return;
                }
            } catch(e) { console.warn('Peak extraction failed:', e); }
        }

        this._peaksInjected = false;
        this.totalEl.textContent = `${formatTime(this.totalDuration)}`;
        this.wavesurferInstance.setVolume(parseFloat(this.volumeSlider.value));
        this.drawRegions();
        this.#updateNavButtons();

        // Zoom to show 20 s by default; attach scroll listener so the region lane stays in sync
        requestAnimationFrame(() => {
            const W = this.waveformEl.clientWidth;
            if (W > 0 && this.totalDuration > 0) {
                this.wavesurferInstance.zoom(W / Math.min(20, this.totalDuration));
            }
            const scrollEl = this.#getScrollEl();
            if (scrollEl) scrollEl.addEventListener('scroll', () => this.drawRegions(), { passive: true });
        });
    }

    /**
     * RAF-throttled handler for WaveSurfer's 'audioprocess' event that updates
     * the timecode display and syncs the active segment.
     * @param {number} time - Current playback position in seconds.
     */
    #onTimeUpdate(time) {
        this._pendingTime = time;
        if (this._rafPending) return;
        this._rafPending = true;
        requestAnimationFrame(() => {
            this._rafPending = false;
            const t = this._pendingTime;
            this.playbackProgress = this.totalDuration > 0 ? t / this.totalDuration : 0;
            this.currentEl.textContent = formatTimeMs(t);
            if (this.activeProject?.hasTranscript) {
                this.onRegionActivate(this.#regionAtTime(t));
                this.drawRegions();
                this.#updateNavButtons();
            }
        });
    }

    /**
     * Returns the scrollable container element created by WaveSurfer, or null.
     * @returns {HTMLElement|null}
     */
    #getScrollEl() {
        if (!this.wavesurferInstance?.getWrapper) return null;
        return this.wavesurferInstance.getWrapper()?.parentElement ?? null;
    }

    /**
     * Returns the index of the transcript segment that contains the given time.
     * @param {number} t - Time in seconds.
     * @returns {number} Segment index, or -1 if none matches.
     */
    #regionAtTime(t) {
        if (!this.activeProject?.hasTranscript) return -1;
        return this.activeProject.transcript().segments.findIndex(s => t >= s.start && t < s.end);
    }

    // Last segment index whose start ≤ t
    /**
     * Returns the index of the last segment whose start time is at or before t.
     * @param {number} t - Time in seconds.
     * @returns {number} Segment index, or -1 if none qualify.
     */
    #segIdxAtOrBefore(t) {
        const segs = this.activeProject?.transcript().segments ?? [];
        let idx = -1;
        for (let i = 0; i < segs.length; i++) {
            if (segs[i].start <= t + 0.001) idx = i; else break;
        }
        return idx;
    }

    // Last paragraph index whose first-segment start ≤ t
    /**
     * Returns the index of the last paragraph whose first segment starts at or before t.
     * @param {number} t - Time in seconds.
     * @returns {number} Paragraph index, or -1 if none qualify.
     */
    #paraIdxAtOrBefore(t) {
        const paras = this.activeProject?.transcript().paragraphs ?? [];
        let idx = -1;
        for (let i = 0; i < paras.length; i++) {
            if (paras[i].segments[0].start <= t + 0.001) idx = i; else break;
        }
        return idx;
    }

    /**
     * Returns the current playback position in seconds.
     * @returns {number}
     */
    #currentTime() { return this.totalDuration * this.playbackProgress; }

    /**
     * Seeks to the start of the previous segment, or to the start of the current
     * segment if already more than 0.5 s into it.
     */
    #navPrevSeg() {
        if (!this.wavesurferInstance || !this.totalDuration) return;
        const t    = this.#currentTime();
        const segs = this.activeProject.transcript().segments;
        const cur  = this.#segIdxAtOrBefore(t);
        // If we're meaningfully into current segment, go to its start; otherwise go to previous
        const target = (cur >= 0 && t - segs[cur].start > 0.5) ? cur : cur - 1;
        if (target >= 0) this.wavesurferInstance.seekTo(segs[target].start / this.totalDuration);
    }

    /**
     * Seeks to the start of the next segment.
     */
    #navNextSeg() {
        if (!this.wavesurferInstance || !this.totalDuration) return;
        const segs = this.activeProject.transcript().segments;
        const next = this.#segIdxAtOrBefore(this.#currentTime()) + 1;
        if (next < segs.length) this.wavesurferInstance.seekTo(segs[next].start / this.totalDuration);
    }

    /**
     * Seeks to the start of the previous paragraph, or to the start of the current
     * paragraph if already more than 0.5 s into it.
     */
    #navPrevPara() {
        if (!this.wavesurferInstance || !this.totalDuration) return;
        const t     = this.#currentTime();
        const paras = this.activeProject.transcript().paragraphs;
        const cur   = this.#paraIdxAtOrBefore(t);
        const paraStart = cur >= 0 ? paras[cur].segments[0].start : 0;
        const target = (cur >= 0 && t - paraStart > 0.5) ? cur : cur - 1;
        if (target >= 0) this.wavesurferInstance.seekTo(paras[target].segments[0].start / this.totalDuration);
    }

    /**
     * Seeks to the start of the next paragraph.
     */
    #navNextPara() {
        if (!this.wavesurferInstance || !this.totalDuration) return;
        const paras = this.activeProject.transcript().paragraphs;
        const next  = this.#paraIdxAtOrBefore(this.#currentTime()) + 1;
        if (next < paras.length) this.wavesurferInstance.seekTo(paras[next].segments[0].start / this.totalDuration);
    }

    /**
     * Enables or disables navigation buttons based on current playhead position.
     */
    #updateNavButtons() {
        if (!this.activeProject?.hasTranscript || !this.totalDuration) {
            Object.values(this.navBtns).forEach(b => { b.disabled = true; });
            return;
        }
        const t     = this.#currentTime();
        const segs  = this.activeProject.transcript().segments;
        const paras = this.activeProject.transcript().paragraphs;
        const curSeg  = this.#segIdxAtOrBefore(t);
        const curPara = this.#paraIdxAtOrBefore(t);

        this.navBtns.prevSeg.disabled  = !(curSeg > 0 || (curSeg === 0 && t - segs[0].start > 0.5));
        this.navBtns.nextSeg.disabled  = curSeg >= segs.length - 1;
        const paraStart = curPara >= 0 ? paras[curPara].segments[0].start : 0;
        this.navBtns.prevPara.disabled = !(curPara > 0 || (curPara === 0 && t - paraStart > 0.5));
        this.navBtns.nextPara.disabled = curPara >= paras.length - 1;
    }

    /**
     * Triggers a region redraw after the hovered segment changes.
     */
    setHoveredRegion()  { this.drawRegions(); }

    /**
     * Seeks to the selected segment and triggers a region redraw.
     * @param {number} idx - Segment index to select and seek to.
     */
    setSelectedRegion(idx) {
        if (idx >= 0 && this.totalDuration > 0) {
            const seg = this.activeProject?.transcript().segments[idx];
            if (seg) this.wavesurferInstance?.seekTo(seg.start / this.totalDuration);
        }
        this.drawRegions();
    }

    /**
     * Repaints the region canvas, drawing one bar per segment coloured by speaker
     * and sized/highlighted according to active, hovered, and selected state.
     */
    drawRegions() {
        if (!this.activeProject?.hasTranscript || !this.activeProject?.hasWaveform || this.totalDuration <= 0) return;

        const laneEl   = this.regionLane;
        const dpr      = window.devicePixelRatio || 1;
        const W        = laneEl.clientWidth;
        const H        = laneEl.clientHeight;
        const scrollEl = this.#getScrollEl();
        const totalW   = scrollEl ? scrollEl.scrollWidth : W;
        const scrollX  = scrollEl ? scrollEl.scrollLeft  : 0;

        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);

        const currentT = this.totalDuration * this.playbackProgress;
        const MIN_W    = 3;
        const GAP      = 1;
        const H_NORMAL = Math.round(H * 0.70);
        const H_HOVER  = Math.round(H * 0.88);
        const H_BIG    = H;
        const RAD      = 4;
        const LABEL_Y  = H - H_NORMAL / 2;

        for (const para of this.activeProject.transcript().paragraphs) {
            const spkHex  = this.activeProject.getSpeaker(para.speaker)?.hue ?? '#888888';
            const { r, g, b } = hexToRgb(spkHex);
            const numSegs = para.segments.length;

            const pX0 = (para.segments[0].start           / this.totalDuration) * totalW - scrollX;
            const pX1 = (para.segments[numSegs - 1].end   / this.totalDuration) * totalW - scrollX;
            if (pX1 < 0 || pX0 > W) continue;

            para.segments.forEach((seg, pos) => {
                const segIdx   = this.activeProject.transcript().segments.indexOf(seg);
                const isFirst  = pos === 0;
                const isLast   = pos === numSegs - 1;
                const isActive   = currentT >= seg.start && currentT < seg.end;
                const isSelected = segIdx === this.ctrl.selectedSegmentIdx;
                const isHovered  = segIdx === this.ctrl.hoveredSegmentIdx;

                const nextSeg  = !isLast ? para.segments[pos + 1] : null;
                const rawStart = (seg.start / this.totalDuration) * totalW - scrollX;
                const rawEnd   = nextSeg
                    ? (nextSeg.start / this.totalDuration) * totalW - scrollX
                    : (seg.end       / this.totalDuration) * totalW - scrollX;

                if (rawEnd < 0 || rawStart > W) return;

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

                const ph = isSelected || isActive ? H_BIG : isHovered ? H_HOVER : H_NORMAL;
                const py = H - ph;
                const tl = isFirst ? RAD : 0, bl = isFirst ? RAD : 0;
                const tr = isLast  ? RAD : 0, br = isLast  ? RAD : 0;
                const alpha = isSelected ? 1.0 : isActive ? 0.95 : isHovered ? 0.85 : 0.72;

                ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
                roundRectCorners(ctx, x, py, w, ph, [tl, tr, br, bl]);
                ctx.fill();

                if (isSelected) {
                    ctx.save();
                    ctx.strokeStyle = '#5b5fe0';
                    ctx.lineWidth = 1.5;
                    roundRectCorners(ctx, x + 0.75, py + 0.75, w - 1.5, ph - 1.5, [tl, tr, br, bl]);
                    ctx.stroke();
                    ctx.restore();
                }
            });

            // Speaker name label
            const visX0 = Math.max(0, pX0);
            const visX1 = Math.min(W, pX1);
            const visW  = visX1 - visX0;
            if (visW >= 28) {
                const name = this.activeProject.getSpeaker(para.speaker)?.name ?? '';
                ctx.font = '500 16px "IBM Plex Mono", monospace';
                if (ctx.measureText(name).width + 6 <= visW) {
                    const segs = this.activeProject.transcript().segments;
                    const isRunActive  = para.segments.some(s => currentT >= s.start && currentT < s.end);
                    const isRunHovered = para.segments.some(s => segs.indexOf(s) === this.ctrl.hoveredSegmentIdx);
                    const isRunSel     = para.segments.some(s => segs.indexOf(s) === this.ctrl.selectedSegmentIdx);
                    ctx.save();
                    ctx.beginPath();
                    ctx.rect(visX0, 0, visW, H);
                    ctx.clip();
                    ctx.fillStyle = `rgba(255,255,255,${(isRunActive || isRunHovered || isRunSel) ? 0.95 : 0.8})`;
                    ctx.textAlign    = 'left';
                    ctx.textBaseline = 'middle';
                    ctx.fillText(name, Math.max(visX0, pX0) + 5, LABEL_Y);
                    ctx.restore();
                }
            }
        }
    }
}


// ── PresentationTranscript ────────────────────────────────────────────────────
// Lightweight read-only transcript renderer for presentation mode.
// Renders speaker blocks → paragraphs → inline segment spans.
// Provides setHoveredSegment / setActiveSegment / setSelectedSegment for
// two-way sync with the WaveformPanel.

/**
 * Read-only transcript renderer for presentation mode.
 */
class PresentationTranscript {
    /**
     * @param {HTMLElement} rootEl - Container element for the rendered transcript.
     * @param {PresentationController} ctrl - Shared controller used to seek the waveform on click.
     */
    constructor(rootEl, ctrl) {
        this.root = rootEl;
        this.ctrl = ctrl;   // PresentationController — for seeking the waveform
        this._segEls = [];  // flat array of segment span elements
        this._prevActive   = -1;
        this._prevHovered  = -1;
        this._prevSelected = -1;
    }

    /**
     * Renders the transcript for the given project, building speaker blocks,
     * paragraphs, and clickable/hoverable segment spans.
     * @param {Project} project - The project whose transcript should be rendered.
     */
    loadFromProject(project) {
        this.project = project;
        this._segEls = [];
        this.root.innerHTML = '';

        if (!project.hasTranscript) {
            this.root.innerHTML = '<div class="pt-empty">No transcript available.</div>';
            return;
        }

        const { speakerBlocks, segments } = project.transcript();
        const speakers = project.speakers();

        speakerBlocks.forEach(block => {
            const spk = speakers[block.speaker];
            const blockEl = document.createElement('div');
            blockEl.className = 'pt-block';

            // Speaker label
            const spkEl = document.createElement('div');
            spkEl.className = 'pt-speaker';
            const dot = document.createElement('span');
            dot.className = 'pt-speaker-dot';
            dot.style.background = spk?.hue ?? '#888';
            const nameEl = document.createElement('span');
            nameEl.className = 'pt-speaker-name';
            nameEl.style.color = spk?.hue ?? '#888';
            nameEl.textContent = spk?.name ?? block.speaker;
            spkEl.append(dot, nameEl);
            blockEl.appendChild(spkEl);

            // Paragraphs
            block.paragraphs.forEach(para => {
                const paraEl = document.createElement('p');
                paraEl.className = 'pt-paragraph';

                para.segments.forEach(seg => {
                    const idx = segments.indexOf(seg);
                    const span = document.createElement('span');
                    span.className = 'pt-seg';
                    span.dataset.idx = idx;
                    renderSegmentLinks(span, seg.text, project.annotations, idx);
                    span.appendChild(document.createTextNode(' '));
                    span.addEventListener('click',      () => this._onSegClick(idx));
                    span.addEventListener('mouseenter', () => this.ctrl.onTranscriptHover(idx));
                    span.addEventListener('mouseleave', () => this.ctrl.onTranscriptHover(-1));
                    this._segEls[idx] = span;
                    paraEl.appendChild(span);
                });

                blockEl.appendChild(paraEl);
            });

            this.root.appendChild(blockEl);
        });
    }

    /**
     * Handles a click on a segment span: seeks the waveform and selects the segment.
     * @param {number} idx - Index of the clicked segment.
     */
    _onSegClick(idx) {
        const segs = this.project.transcript().segments;
        const seg = segs[idx];
        if (seg) this.ctrl.seekTo(seg.start);
        this.setSelectedSegment(idx);
        this.ctrl.onTranscriptSelect(idx);
    }

    /**
     * Applies the hovered CSS class to the given segment span.
     * @param {number} idx - Segment index to highlight, or -1 to clear.
     */
    setHoveredSegment(idx) {
        if (this._prevHovered >= 0) this._segEls[this._prevHovered]?.classList.remove('pt-seg-hovered');
        if (idx >= 0) this._segEls[idx]?.classList.add('pt-seg-hovered');
        this._prevHovered = idx;
    }

    /**
     * Applies the active CSS class to the given segment span (playhead position).
     * @param {number} idx - Segment index to mark active, or -1 to clear.
     */
    setActiveSegment(idx) {
        if (this._prevActive >= 0) this._segEls[this._prevActive]?.classList.remove('pt-seg-active');
        if (idx >= 0) this._segEls[idx]?.classList.add('pt-seg-active');
        this._prevActive = idx;
    }

    /**
     * Applies the selected CSS class to the given segment span.
     * @param {number} idx - Segment index to mark selected, or -1 to clear.
     */
    setSelectedSegment(idx) {
        if (this._prevSelected >= 0) this._segEls[this._prevSelected]?.classList.remove('pt-seg-selected');
        if (idx >= 0) this._segEls[idx]?.classList.add('pt-seg-selected');
        this._prevSelected = idx;
    }
}


// ── PresentationController ────────────────────────────────────────────────────
// Minimal workspace shim consumed by WaveformPanel.
// Also owns shared hover/select state and mediates between waveform and transcript.

/**
 * Mediates hover/select/active state between the waveform panel and transcript panel
 * in presentation mode.
 */
class PresentationController {
    /**
     * Initialises state properties; panels must be wired via {@link setPanels}.
     */
    constructor() {
        this.activeProject     = null;
        this.activeSegmentIdx  = -1;
        this.selectedSegmentIdx = -1;
        this.hoveredSegmentIdx = -1;
        this.hoveredSpeakerId  = null;
        this.hoveredParagraphIdx = -1;
        this.searchMatchSet    = null;
        this.splitPopup        = null;

        // Set after panels are created
        this._waveform   = null;
        this._transcript = null;
    }

    // Required by WaveformPanel
    /** @returns {true} Always true — presentation mode is read-only. */
    isReadOnly()          { return true; }
    /** No-op required by WaveformPanel interface. */
    closeCtxMenu()        {}
    /** No-op required by WaveformPanel interface. */
    openSegmentCtxMenu()  {}

    /**
     * Wires the waveform and transcript panels so the controller can forward events.
     * @param {PresentationWaveform} waveformPanel - The waveform player panel.
     * @param {PresentationTranscript} transcriptPanel - The transcript renderer panel.
     */
    setPanels(waveformPanel, transcriptPanel) {
        this._waveform   = waveformPanel;
        this._transcript = transcriptPanel;
    }

    /**
     * Called by the waveform region lane when the pointer moves over a segment.
     * @param {number} idx - Hovered segment index, or -1.
     */
    onRegionHover(idx) {
        this.hoveredSegmentIdx = idx;
        this._transcript?.setHoveredSegment(idx);
    }

    /**
     * Called by the waveform region lane when a segment region is clicked.
     * @param {number} idx - Selected segment index.
     */
    onRegionSelect(idx) {
        this.selectedSegmentIdx = idx;
        this._transcript?.setSelectedSegment(idx);
        this._transcript?.scrollToSegment(idx);
        this._waveform?.drawRegions();
    }

    /**
     * Called by the waveform when the playhead enters a new segment.
     * @param {number} idx - Active segment index, or -1.
     */
    onRegionActivate(idx) {
        this.activeSegmentIdx = idx;
        this._transcript?.setActiveSegment(idx);
    }

    /**
     * Called by the transcript panel when the pointer enters a segment span.
     * @param {number} idx - Hovered segment index, or -1.
     */
    onTranscriptHover(idx) {
        this.hoveredSegmentIdx = idx;
        this._waveform?.setHoveredRegion(idx);
    }

    /**
     * Called by the transcript panel when a segment span is clicked.
     * @param {number} idx - Selected segment index.
     */
    onTranscriptSelect(idx) {
        this.selectedSegmentIdx = idx;
        this._waveform?.setSelectedRegion(idx);
    }

    /**
     * Seeks the WaveSurfer instance to the given time.
     * @param {number} time - Target time in seconds.
     */
    seekTo(time) {
        const ws = this._waveform?.wavesurferInstance;
        if (!ws) return;
        const dur = ws.getDuration();
        if (dur > 0) ws.seekTo(time / dur);
    }
}


// ── Data helpers ──────────────────────────────────────────────────────────────

// Module-level tooltip state for link hover tooltips in presentation mode.
let _ptTooltipEl   = null;
let _ptTooltipTimer = null;
let _ptMouseX = 0;
let _ptMouseY = 0;
document.addEventListener('mousemove', (e) => { _ptMouseX = e.clientX; _ptMouseY = e.clientY; });

/**
 * Shows a tooltip near the cursor with link metadata.
 * @param {string} url - the hyperlink URL
 * @param {string|null} name - optional display name for the link
 * @param {string|null} description - optional description text
 */
function _showPtLinkTooltip(url, name, description) {
    _hidePtLinkTooltip();
    const el = document.createElement('div');
    el.className = 'info-widget-tooltip link-tooltip';

    if (name) {
        const nameEl = document.createElement('div');
        nameEl.className = 'link-tooltip-name';
        nameEl.textContent = name;
        el.appendChild(nameEl);
    }

    const urlEl = document.createElement('div');
    urlEl.className = 'link-tooltip-url';
    urlEl.textContent = url;
    el.appendChild(urlEl);

    if (description) {
        const descEl = document.createElement('div');
        descEl.className = 'link-tooltip-desc';
        descEl.textContent = description;
        el.appendChild(descEl);
    }

    const footer = document.createElement('div');
    footer.className = 'link-tooltip-footer';
    footer.textContent = 'Click to navigate ↗';
    el.appendChild(footer);

    document.body.appendChild(el);
    _ptTooltipEl = el;

    const x = _ptMouseX + 12;
    const y = _ptMouseY + 16;
    el.style.left = x + 'px';
    el.style.top  = y + 'px';
    requestAnimationFrame(() => {
        const r = el.getBoundingClientRect();
        if (r.right  > window.innerWidth)  el.style.left = (window.innerWidth  - r.width  - 8) + 'px';
        if (r.bottom > window.innerHeight) el.style.top  = (_ptMouseY - r.height - 4) + 'px';
    });
}

/** Hides and removes the active link tooltip. */
function _hidePtLinkTooltip() {
    clearTimeout(_ptTooltipTimer);
    _ptTooltipEl?.remove();
    _ptTooltipEl = null;
}

/**
 * Populates a segment container with plain text and styled link spans.
 * Mirrors the workspace #renderHlContent / #getHyperlinksForSegment logic.
 * In presentation mode links are directly clickable (no Ctrl required).
 * Editor notes are never shown (presentation is always read-only).
 * @param {HTMLElement} container - the DOM element to populate
 * @param {string} text - the segment's plain text content
 * @param {object|null} annotations - the project annotations object containing hyperlinks
 * @param {number} segIdx - index of the segment within the transcript
 */
function renderSegmentLinks(container, text, annotations, segIdx) {
    const hyperlinks = annotations?.hyperlinks;
    if (!hyperlinks) { container.textContent = text; return; }

    const links = Object.entries(hyperlinks)
        .filter(([, h]) => h.segmentIdx === segIdx)
        .map(([, h]) => {
            let cs = h.charStart ?? 0;
            let ce = h.charEnd   ?? text.length;
            while (cs < ce && /\s/.test(text[cs])) cs++;
            while (ce > cs && /\s/.test(text[ce - 1])) ce--;
            return {
                url:         h.url,
                name:        h.name        ?? null,
                description: h.description ?? null,
                charStart:   cs,
                charEnd:     ce,
            };
        })
        .filter(h => h.charStart < h.charEnd)
        .sort((a, b) => a.charStart - b.charStart);

    if (!links.length) { container.textContent = text; return; }

    let pos = 0;
    for (const link of links) {
        if (link.charStart > pos) {
            container.appendChild(document.createTextNode(text.slice(pos, link.charStart)));
        }
        const href = /^https?:\/\//i.test(link.url) ? link.url : 'https://' + link.url;
        const span = document.createElement('span');
        span.className = 'pt-seg-link';
        span.textContent = text.slice(link.charStart, link.charEnd);
        span.addEventListener('mouseenter', () => {
            _ptTooltipTimer = setTimeout(() => _showPtLinkTooltip(link.url, link.name, link.description), 300);
        });
        span.addEventListener('mouseleave', _hidePtLinkTooltip);
        span.addEventListener('click', (e) => {
            e.stopPropagation();
            _hidePtLinkTooltip();
            window.open(href, '_blank', 'noopener,noreferrer');
        });
        container.appendChild(span);
        pos = link.charEnd;
    }
    if (pos < text.length) {
        container.appendChild(document.createTextNode(text.slice(pos)));
    }
}

/**
 * Constructs a Project instance from raw server presentation data.
 * @param {object} pdata - presentation data payload from the server
 * @returns {Project}
 */
function buildProject(pdata) {
    const { project, waveform, segments, audioUrl, annotations } = pdata;
    const proj = new Project(project.id, project.name, {});
    proj.localOnly = false;

    // Load speakers
    const speakersData = project.speakers || {};
    for (const [id, spk] of Object.entries(speakersData)) {
        proj.addSpeaker(id, spk.name, spk.hue, {}, false);
    }
    if (Object.keys(speakersData).length) {
        proj.hasSpeakers = true;
        proj.local.speakers = { ...proj.server.speakers };
    }

    // Load transcript
    if (segments?.length) {
        const t = new Transcript(segments);
        proj.server.transcript = t;
        proj.local.transcript  = t;
        proj.hasTranscript = true;
    }

    // Load waveform
    if (waveform && Object.keys(waveform).length) {
        const peaks = waveform.peaks?.length
            ? [new Float32Array(waveform.peaks)]
            : null;
        const wf = new Waveform({
            url:        audioUrl,
            sampleRate: waveform.sampleRate ?? -1,
            duration:   waveform.duration   ?? -1,
            filename:   waveform.filename   ?? 'audio.mp3',
            peaks,
        });
        proj.server.waveform = wf;
        proj.local.waveform  = wf;
        proj.hasWaveform = true;
    }

    // Annotations (hyperlinks)
    if (annotations) proj.annotations = annotations;

    // No-op server shim — prevents WaveformPanel from crashing when it tries
    // to save computed waveform peaks after audio decoding.
    proj.activeServer = {
        isConnected: false,
        saveWaveform:    () => Promise.resolve(),
        checkAudioReady: () => Promise.resolve(false),
    };

    return proj;
}

/**
 * Fetches project metadata, waveform data, and transcript from the API,
 * returning a combined data object suitable for {@link buildProject}.
 * @param {string} projectId - The project UUID.
 * @param {string|null} token - Firebase ID token for authenticated requests, or null.
 * @returns {Promise<object>}
 */
async function fetchProjectData(projectId, token) {
    const headers = token ? { 'X-Auth-Token': token } : {};
    const base = window.location.origin;

    const [meta, wf, transcriptText, annotations] = await Promise.all([
        fetch(`${base}/api/projects/${projectId}`,             { headers, credentials: 'include' }).then(r => { if (!r.ok) throw r.status; return r.json(); }),
        fetch(`${base}/api/projects/${projectId}/waveform`,    { headers, credentials: 'include' }).then(r => r.ok ? r.json() : {}),
        fetch(`${base}/api/projects/${projectId}/transcript`,  { headers, credentials: 'include' }).then(r => r.ok ? r.text() : null),
        fetch(`${base}/api/projects/${projectId}/annotations`, { headers, credentials: 'include' }).then(r => r.ok ? r.json() : { hyperlinks: {} }),
    ]);

    // Parse CSV transcript
    let segments = [];
    if (transcriptText) {
        const lines = transcriptText.trim().split('\n');
        for (let i = 1; i < lines.length; i++) {   // skip header
            const [start, end, speaker, ...rest] = lines[i].split(',');
            segments.push({ start: parseFloat(start), end: parseFloat(end), speaker, text: rest.join(',').replace(/^"|"$/g, '') });
        }
    }

    const audioUrl = token
        ? `${base}/api/projects/${projectId}/audio?token=${encodeURIComponent(token)}`
        : `${base}/presentation/${projectId}/audio`;

    return { project: meta, waveform: wf, segments, audioUrl, anyWithLink: false, annotations };
}


// ── Show / hide helpers ───────────────────────────────────────────────────────

/**
 * Shows the named UI state panel (loading, auth required, or no access)
 * and hides the others.
 * @param {'presLoading'|'presAuthRequired'|'presNoAccess'} id - ID of the panel to show.
 */
function showState(id) {
    ['presLoading','presAuthRequired','presNoAccess'].forEach(s => {
        document.getElementById(s).style.display = (s === id) ? '' : 'none';
    });
}

/**
 * Hides all state panels and reveals the waveform and transcript sections.
 */
function showPresentation() {
    ['presLoading','presAuthRequired','presNoAccess'].forEach(s => {
        document.getElementById(s).style.display = 'none';
    });
    document.getElementById('presWaveformSection').style.display  = '';
    document.getElementById('presTranscriptSection').style.display = '';
}


// ── Transcript search ─────────────────────────────────────────────────────────

/**
 * Injects <mark class="pt-search-hl"> wrappers around all occurrences of
 * `query` within the text nodes of `el`.
 * @param {HTMLElement} el - element whose text nodes will be wrapped with highlights
 * @param {string} query - already lower-cased search string
 */
function _highlightTextInEl(el, query) {
    const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
    const textNodes = [];
    let node;
    while ((node = walker.nextNode())) textNodes.push(node);

    textNodes.forEach(textNode => {
        const text  = textNode.textContent;
        const lower = text.toLowerCase();
        if (!lower.includes(query)) return;

        const frag = document.createDocumentFragment();
        let pos = 0, idx = lower.indexOf(query, 0);
        while (idx !== -1) {
            if (idx > pos) frag.appendChild(document.createTextNode(text.slice(pos, idx)));
            const mark = document.createElement('mark');
            mark.className = 'pt-search-hl';
            mark.textContent = text.slice(idx, idx + query.length);
            frag.appendChild(mark);
            pos = idx + query.length;
            idx = lower.indexOf(query, pos);
        }
        if (pos < text.length) frag.appendChild(document.createTextNode(text.slice(pos)));
        textNode.parentNode.replaceChild(frag, textNode);
    });
}

/**
 * Removes all <mark class="pt-search-hl"> elements from `el`, restoring plain text.
 * @param {HTMLElement} el - element from which highlights will be removed
 */
function _clearTextHighlights(el) {
    el.querySelectorAll('mark.pt-search-hl').forEach(mark => {
        mark.replaceWith(document.createTextNode(mark.textContent));
    });
    el.normalize();
}

/**
 * Wires the transcript search bar.  The magnifying-glass button collapses and
 * expands the bar; text highlights are injected directly into segment DOM nodes.
 * @param {PresentationTranscript} presTranscript - The rendered transcript panel.
 */
function initSearch(presTranscript) {
    const searchBar  = document.getElementById('presSearchBar');
    const toggleBtn  = document.getElementById('presSearchToggle');
    const fields     = document.getElementById('presSearchFields');
    const input      = document.getElementById('presSearchInput');
    const countEl    = document.getElementById('presSearchCount');
    const prevBtn    = document.getElementById('presSearchPrev');
    const nextBtn    = document.getElementById('presSearchNext');

    let matches    = [];   // segment indices that match current query
    let focusedIdx = -1;   // index into matches[] of the focused result

    /** Opens the search bar and focuses the input. */
    function open() {
        searchBar.classList.add('open');
        fields.style.display = 'flex';
        input.focus();
        input.select();
    }

    /** Closes the search bar and clears all highlights and state. */
    function close() {
        searchBar.classList.remove('open');
        fields.style.display = 'none';
        _clearMatches();
        input.value = '';
    }

    /** Clears all search match highlights and resets match state. */
    function _clearMatches() {
        matches.forEach(idx => {
            const el = presTranscript._segEls[idx];
            if (!el) return;
            el.classList.remove('pt-seg-search-focused');
            _clearTextHighlights(el);
        });
        matches    = [];
        focusedIdx = -1;
        countEl.textContent = '';
        prevBtn.disabled = true;
        nextBtn.disabled = true;
    }

    /**
     * Moves focus to the match at index `i` (wrapping), scrolls it into view, and updates the count label.
     * @param {number} i - index into the matches array to focus
     */
    function _focusMatch(i) {
        if (!matches.length) return;
        presTranscript._segEls[matches[focusedIdx]]?.classList.remove('pt-seg-search-focused');
        focusedIdx = ((i % matches.length) + matches.length) % matches.length;
        const el = presTranscript._segEls[matches[focusedIdx]];
        el?.classList.add('pt-seg-search-focused');
        el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
        countEl.textContent = `${focusedIdx + 1} / ${matches.length}`;
    }

    /** Runs the current search query against all segment elements and highlights matches. */
    function _runSearch() {
        _clearMatches();
        const q = input.value.trim().toLowerCase();
        if (!q) return;

        presTranscript._segEls.forEach((el, idx) => {
            if (!el) return;
            if (el.textContent.toLowerCase().includes(q)) {
                _highlightTextInEl(el, q);
                matches.push(idx);
            }
        });

        if (matches.length) {
            prevBtn.disabled = false;
            nextBtn.disabled = false;
            _focusMatch(0);
        } else {
            countEl.textContent = 'No matches';
        }
    }

    // Magnifying glass toggles open/close
    toggleBtn.addEventListener('click', () => {
        searchBar.classList.contains('open') ? close() : open();
    });

    prevBtn.addEventListener('click', () => _focusMatch(focusedIdx - 1));
    nextBtn.addEventListener('click', () => _focusMatch(focusedIdx + 1));

    input.addEventListener('input', _runSearch);
    input.addEventListener('keydown', (e) => {
        if (e.key === 'Escape') { close(); return; }
        if (e.key === 'Enter') {
            e.preventDefault();
            _focusMatch(e.shiftKey ? focusedIdx - 1 : focusedIdx + 1);
        }
    });

    document.addEventListener('keydown', (e) => {
        if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
            e.preventDefault();
            searchBar.classList.contains('open') ? (input.focus(), input.select()) : open();
        }
    });
}


// ── Copy link ─────────────────────────────────────────────────────────────────

/**
 * Wires the "Copy link" button to copy the current page URL to the clipboard.
 */
function initCopyBtn() {
    const btn = document.getElementById('presCopyBtn');
    btn.addEventListener('click', () => {
        navigator.clipboard.writeText(window.location.href).then(() => {
            btn.classList.add('copied');
            btn.textContent = '✓ Copied!';
            setTimeout(() => {
                btn.classList.remove('copied');
                btn.innerHTML = '<span class="pres-copy-icon">&#x1F517;</span> Copy link';
            }, 2000);
        });
    });
}


// ── Bootstrap ─────────────────────────────────────────────────────────────────

/**
 * Entry point: loads project data (from embedded SSR payload or via Firebase auth + API),
 * constructs the controller and panels, and mounts everything into the page.
 * @returns {Promise<void>}
 */
async function init() {
    const pdata = window.PDATA;
    initCopyBtn();

    let project;

    if (pdata.anyWithLink || pdata.waveform !== null) {
        // SSR path: all data embedded
        project = buildProject(pdata);
        document.getElementById('presTitle').textContent = project.projectName;

    } else {
        // Auth path: need Firebase login then fetch data
        const cfg = window.FIREBASE_CONFIG;
        if (!cfg?.apiKey) {
            showState('presNoAccess');
            return;
        }

        // Dynamically load Firebase
        const { initializeApp }  = await import('https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js');
        const { getAuth, onAuthStateChanged, GoogleAuthProvider, signInWithPopup }
            = await import('https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js');

        const app  = initializeApp(cfg);
        const auth = getAuth(app);

        document.getElementById('presSigninBtn').addEventListener('click', () => {
            signInWithPopup(auth, new GoogleAuthProvider()).catch(console.error);
        });

        const user = await new Promise(resolve => {
            const unsub = onAuthStateChanged(auth, u => { unsub(); resolve(u); });
        });

        if (!user) {
            showState('presAuthRequired');
            return;
        }

        let token;
        try { token = await user.getIdToken(); } catch { showState('presNoAccess'); return; }

        let fetched;
        try {
            fetched = await fetchProjectData(pdata.project.id, token);
        } catch (status) {
            showState(status === 403 ? 'presNoAccess' : 'presAuthRequired');
            return;
        }

        project = buildProject(fetched);
        document.getElementById('presTitle').textContent = project.projectName;
    }

    // ── Build panels ──────────────────────────────────────────────────────────

    const ctrl = new PresentationController();
    ctrl.activeProject = project;

    const wfMount = document.getElementById('presWaveformMount');
    const presWaveform = new PresentationWaveform(wfMount, ctrl, {
        onRegionHover:    idx => ctrl.onRegionHover(idx),
        onRegionSelect:   idx => ctrl.onRegionSelect(idx),
        onRegionActivate: idx => ctrl.onRegionActivate(idx),
    });

    const transcriptEl = document.getElementById('presTranscript');
    const presTranscript = new PresentationTranscript(transcriptEl, ctrl);
    presTranscript.scrollToSegment = (idx) => {
        const el = transcriptEl.querySelector(`.pt-seg[data-idx="${idx}"]`);
        el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    };

    ctrl.setPanels(presWaveform, presTranscript);
    initSearch(presTranscript);

    // ── Load project into panels ──────────────────────────────────────────────

    showPresentation();
    presWaveform.loadFromProject(project);
    presTranscript.loadFromProject(project);

    // ── Sticky shadow: add class when player group has left natural position ─────
    const sentinel    = document.getElementById('presWaveformSentinel');
    const playerGroup = document.getElementById('presPlayerGroup');
    new IntersectionObserver(([entry]) => {
        // Only "stuck" when the sentinel has scrolled above the viewport (top < 0),
        // not when it is below the fold and not yet reached.
        const stuck = !entry.isIntersecting && entry.boundingClientRect.top < 0;
        playerGroup.classList.toggle('pres-waveform-stuck', stuck);
    }).observe(sentinel);

    // ── Search anchor sticky top: keep it just below the sticky waveform ─────
    const searchAnchor = document.getElementById('presSearchAnchor');
    const HEADER_H = 56;
    /** Updates the search anchor's top offset to stay just below the sticky waveform player. */
    function _updateSearchAnchorTop() {
        searchAnchor.style.top = (HEADER_H + playerGroup.offsetHeight + 8) + 'px';
    }
    _updateSearchAnchorTop();
    new ResizeObserver(_updateSearchAnchorTop).observe(playerGroup);
}

init().catch(err => {
    console.error('Presentation init failed:', err);
    showState('presNoAccess');
});