workspace_panels_speakers_panel.js

import { formatTimeMs, hexToHue } from "../utilities/tools.js"
import { HuePicker } from "../components/hue_picker.js"
import { ConfirmDialog } from "../components/confirm_dialog.js"
import { encodeMonoWav } from "../utilities/audio.js"

/**
 * Panel that lists all speakers in the active project, allowing the user to
 * rename them, change their color, attach a voice sample, and delete them.
 * Communicates back to the workspace via onSpeakerHover and onSpeakerModified callbacks.
 */
export class SpeakersPanel {

    /**
     * @param {object} workspace - the Workspace controller instance
     * @param {object} callbacks - callback functions for speaker interactions
     * @param {function} callbacks.onSpeakerHover - called with speaker id when a speaker is hovered
     * @param {function} callbacks.onSpeakerModified - called with speaker when a speaker is modified
     */
    constructor(workspace, { onSpeakerHover, onSpeakerModified }) {
        this.workspace = workspace;
        this.onSpeakerHover = onSpeakerHover ?? (() => {})
        this.onSpeakerModified = onSpeakerModified ?? (() => {})

        this.currentColorIndex = 0;

        this.hoveredSpeakerId = null;

        this.activeHuePicker = null;

        // Active recording state — at most one recording session at a time
        this.activeRecorder = null; // { id, stream, recorder, chunks, stopFn }

        this.activeProject = null;

        this.#getElements();
        this.#setupListeners();
    }

    /** Attaches click handler to the "Add Speaker" button. */
    #setupListeners() {
        this.addSpeakerBtn.addEventListener('click', () => { this.addSpeaker(); });
    }

    /** Binds panel DOM elements to instance properties. */
    #getElements() {
        this.root = document.getElementById('speakersPanel');
        this.addSpeakerBtn = this.root.querySelector('#addSpeakerBtn');
        this.speakersEmpty = this.root.querySelector('#speakersEmpty');
        this.speakersTable = this.root.querySelector('#speakersTable');
        this.speakersTableBody = this.root.querySelector('#speakersTableBody');
    }

    /** Clears the panel by re-rendering with no active project. */
    clearSpeakersPanel() {
        this.renderSpeakersPanel();
    }

    /**
    * Loads speaker data from the given project and renders the panel.
    * @param {Project} project - the project to load speaker data from
    */
    loadFromProject(project) {
        this.activeProject = project;
        this.renderSpeakersPanel()
    }


    /**
    * Adds a new speaker with an auto-generated unique id (SPEAKER_N where N
    * avoids collisions). Immediately focuses the new name span so the user can
    * type a custom name right away.
    * @param {string|null} [speakerId=null] - explicit id to assign; auto-generated if null
    */
    addSpeaker(speakerId = null) {
        let newId = speakerId;

        // if an ID is passed, just use it.  Otherwise, generate a new id.
        if (!newId) {
            // Find the next N such that SPEAKER_N doesn't already exist
            let n = Object.keys(this.activeProject.speakers()).length + 1;
            newId = `SPEAKER_${String(n).padStart(2, '0')}`

//            while (this.activeProject.getSpeaker(speakerId)) {
//                newId = `SPEAKER_${++n}`;
//            }
        }


        this.activeProject.addSpeaker(newId, newId);
        this.activeProject.markSpeakersDirty();

        const created = this.activeProject.speakers()[newId];
        this.workspace.history.push({
            label: 'Add speaker', dirtyFlags: ['speakers'],
            undo: () => { delete this.activeProject.speakers()[newId]; },
            redo: () => { this.activeProject.speakers()[newId] = created; },
        });
        this.workspace._updateUndoRedoButtons();

        // Focus the new speaker's name span for immediate rename
        requestAnimationFrame(() => {
            const spans = this.root.querySelectorAll('.speaker-name');
            const last = spans[spans.length - 1];
            if (last) {
                last.click();
            }
        });
    }


    /** Closes and removes the active hue picker if one is open. */
    closeHuePicker() {
        if (this.activeHuePicker) { this.activeHuePicker.remove(); this.activeHuePicker = null; }
    }

    /**
     * Opens a hue picker popover anchored below `swatchEl`.
     * @param {object}      speaker - the speaker object whose hue should be changed
     * @param {HTMLElement} swatchEl - the color swatch that was clicked
     */
    openHuePicker(speaker, swatchEl) {
      this.closeHuePicker();

      const oldHue = speaker.hue;

      this.activeHuePicker = new HuePicker({
        onSelect: (newColor) => {
          speaker.hue = newColor;
          document.querySelectorAll(`.speaker-name[data-speaker-id="${speaker.id}"]`).forEach(el => {
            el.style.color = newColor;
          });
          swatchEl.style.background = newColor;
          this.onSpeakerModified(speaker);
          this.workspace.history.push({
              label: 'Change speaker color', dirtyFlags: ['speakers'],
              undo: () => { speaker.hue = oldHue; },
              redo: () => { speaker.hue = newColor; },
          });
          this.workspace._updateUndoRedoButtons();
        },
        onCancel: () => {
          // nothing extra needed on cancel; picker removes itself
        },
      });

      this.activeHuePicker.open(swatchEl, hexToHue(speaker.hue));
    }

// ── Speaker voice samples ─────────────────────────────────────────────────

    /**
    * Decodes an audio ArrayBuffer and extracts 120 peak bars for the mini
    * waveform visualization.
    * @param {ArrayBuffer} arrayBuffer - raw audio data to decode
    * @returns {Promise<{audioBuffer: AudioBuffer, peaks: number[]}>}
    */
    async decodeSampleBuffer(arrayBuffer) {
        const actx = new (window.AudioContext || window.webkitAudioContext)();
        const audioBuffer = await actx.decodeAudioData(arrayBuffer);
        // Build peaks from the first channel only (samples are typically mono)
        const data = audioBuffer.getChannelData(0);
        const numBars = 120;
        const blockSize = Math.floor(data.length / numBars);
        const peaks = [];
        for (let i = 0; i < numBars; i++) {
          let max = 0;
          for (let j = 0; j < blockSize; j++) max = Math.max(max, Math.abs(data[i * blockSize + j]));
          peaks.push(max);
        }
        actx.close();
        return { audioBuffer, peaks };
    }

    /**
    * Stores a decoded speaker sample in state. Revokes any previously stored
    * blob URL to avoid memory leaks, then re-renders the speakers panel.
    * @param {string} id - speaker id
    * @param {AudioBuffer} audioBuffer - the decoded audio buffer
    * @param {string|null} blobUrl - blob URL for re-upload to server, or null if not needed
    * @param {number[]} peaks - 120-bar peak array for waveform display
    */
    setSpeakerSample(id, audioBuffer, blobUrl, peaks) {
        const speaker = this.activeProject.speakers()[id];
        if (speaker.sample?.blobUrl) {
            URL.revokeObjectURL(speaker.sample.blobUrl);
        }
        speaker.sample = { audioBuffer, blobUrl, peaks };
        this.activeProject.markSpeakersDirty();
    }

    /**
    * Removes a speaker's voice sample and revokes its blob URL.
    * @param {string} id - speaker id
    */
    removeSpeakerSample(id) {
        const speaker = this.activeProject.speakers()[id];
        if (speaker.sample?.blobUrl) {
            URL.revokeObjectURL(speaker.sample.blobUrl);
        }
        delete speaker.sample;
        this.activeProject.markSpeakersDirty();
    }

    /**
    * Renders waveform peak bars into a canvas element.
    * Bars to the left of `progress` are drawn at full opacity (played),
    * bars to the right at reduced opacity (unplayed).
    * @param {HTMLCanvasElement} canvas - target canvas element to draw into
    * @param {number[]} peaks - array of 0..1 amplitude values
    * @param {number} [progress=0] - playback fraction 0..1
    * @param {string} [color='#47c8ff'] - hex color for the bars
    */
    drawSampleWaveform(canvas, peaks, progress = 0, color = '#47c8ff') {
        const dpr = window.devicePixelRatio || 1;
        const W = canvas.clientWidth || canvas.offsetWidth || 120;
        const H = canvas.clientHeight || canvas.offsetHeight || 28;
        canvas.width  = W * dpr;
        canvas.height = H * dpr;
        const ctx = canvas.getContext('2d');
        ctx.scale(dpr, dpr);
        ctx.clearRect(0, 0, W, H);
        // Parse hex to RGB for rgba() fill
        const cx = parseInt(color.replace('#',''), 16);
        const r = (cx >> 16) & 255, g = (cx >> 8) & 255, b = cx & 255;
        const mid = H / 2;
        for (let x = 0; x < W; x++) {
            const pi = Math.floor((x / W) * peaks.length);
            const amp = Math.max(peaks[pi] * mid * 0.9, 0.5); // minimum visible height of 0.5px
            const played = x / W < progress;
            ctx.fillStyle = played ? `rgba(${r},${g},${b},0.9)` : `rgba(${r},${g},${b},0.35)`;
            ctx.fillRect(x, mid - amp, 1, amp * 2);
        }
    }

    /**
    * Plays a speaker voice sample using the Web Audio API and animates a progress
    * line over the mini waveform. Clicking the play button a second time stops
    * playback early.
    * @param {object} speaker - the speaker object whose sample will be played
    * @param {HTMLButtonElement} playBtn - the play/stop button element
    * @param {HTMLCanvasElement} canvas - waveform canvas to animate during playback
    * @param {number[]} peaks - 120-bar peak array for waveform display
    * @param {HTMLElement} progressLine - thin overlay element showing play position
    */
    playSampleAudio(speaker, playBtn, canvas, peaks, progressLine) {
        const sample = speaker.sample;
        if (!sample) {
            return;
        }
        const actx = new (window.AudioContext || window.webkitAudioContext)();
        const src = actx.createBufferSource();
        src.buffer = sample.audioBuffer;
        src.connect(actx.destination);
        const dur = sample.audioBuffer.duration;
        const startTime = actx.currentTime;
        playBtn.textContent = '⏹';
        progressLine.style.display = 'block';
        let raf;
        const color = speaker.hue;

        /** rAF loop: updates waveform shading and progress line position each frame */
        const tick = () => {
            const elapsed = actx.currentTime - startTime;
            const frac = Math.min(elapsed / dur, 1);
            this.drawSampleWaveform(canvas, peaks, frac, color);
            const W = canvas.clientWidth || 120;
            progressLine.style.left = (frac * W) + 'px';
            if (frac < 1) { raf = requestAnimationFrame(tick); }
            else { finish(); }
        }

        /** Cleanup: cancel rAF, reset button, hide progress line, close AudioContext */
        const finish = () => {
            cancelAnimationFrame(raf);
            playBtn.textContent = '▶';
            progressLine.style.display = 'none';
            this.drawSampleWaveform(canvas, peaks, 0, color); // reset to unplayed state
            actx.close();
        }
        src.onended = finish;
        src.start();
        raf = requestAnimationFrame(tick);
        // Stop on second click
        playBtn.onclick = () => {
            src.stop();
            finish();
            playBtn.onclick = null;
        };
    }

    /**
    * Opens a hidden file input dialog for uploading a voice sample.
    * On selection, decodes the audio and stores it via setSpeakerSample.
    * @param {string} id - speaker id
    */
    openSampleUpload(id) {
        const input = document.createElement('input');
        input.type = 'file'; input.accept = 'audio/*';
        input.onchange = async () => {
          const file = input.files[0];
          if (!file) return;
          const arrayBuffer = await file.arrayBuffer();
          const blob = new Blob([arrayBuffer], { type: file.type });
          const blobUrl = URL.createObjectURL(blob);
          // Use a copy of the buffer (.slice(0)) because decodeAudioData detaches it
          const { audioBuffer, peaks } = await this.decodeSampleBuffer(arrayBuffer.slice(0));
          this.setSpeakerSample(id, audioBuffer, blobUrl, peaks);
        };
        input.click();
    }

    /**
    * Starts microphone recording for a speaker voice sample (or stops if already
    * recording). Auto-stops after 20 seconds. On stop, encodes to a Blob and
    * stores via setSpeakerSample.
    * @param {string} id - speaker id
    * @param {HTMLButtonElement} btn - the ⏺ Rec button; its text is toggled
    */
    startRecording(id, btn) {
        if (this.activeRecorder) { this.activeRecorder.stopFn(); return; } // clicking again stops recording
        navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
          const recorder = new MediaRecorder(stream);
          const chunks = [];
          recorder.ondataavailable = e => chunks.push(e.data);
          recorder.onstop = async () => {
            stream.getTracks().forEach(t => t.stop()); // release microphone
            this.activeRecorder = null;
            btn.textContent = '⏺ REC';
            btn.classList.remove('rec-active');
            const blob = new Blob(chunks, { type: 'audio/webm' });
            const blobUrl = URL.createObjectURL(blob);
            const arrayBuffer = await blob.arrayBuffer();
            const { audioBuffer, peaks } = await this.decodeSampleBuffer(arrayBuffer);
            this.setSpeakerSample(id, audioBuffer, blobUrl, peaks);
          };
          // Max 20s auto-stop
          const timeout = setTimeout(() => recorder.stop(), 20000);
          const stopFn = () => { clearTimeout(timeout); recorder.stop(); };
          this.activeRecorder = { id, stream, recorder, chunks, stopFn };
          recorder.start();
          btn.textContent = '⏹ STOP';
          btn.classList.add('rec-active');
        }).catch(() => alert('Microphone access denied.'));
    }

    /**
    * Opens the segment picker modal, showing all transcript segments.
    * Clicking a row slices that segment's audio as the speaker's voice sample.
    * If the selected segment belongs to a different speaker, a confirmation
    * dialog is shown first.
    * @param {object} speaker - the speaker object for whom the sample will be set
    */
    openSegPicker(speaker) {
        const wavesurferInstance = this.workspace.wavesurferInstance();
        const totalDuration = this.activeProject.waveform().duration;;;

        // Show all segments, not just this speaker's — lets user hear context too
        const segs = this.activeProject.transcript().segments;
        if (!segs.length) { alert('No transcript segments loaded.'); return; }

        let pickerPlayRaf = null;
        let pickerPlayBtn = null;

        /** Stops any currently playing segment preview within the picker. */
        const stopPickerPlay = () => {
            if (pickerPlayBtn) {
                pickerPlayBtn.textContent = '▶';
                pickerPlayBtn.classList.remove('playing');
                pickerPlayBtn = null;
            }
            if (pickerPlayRaf) {
                cancelAnimationFrame(pickerPlayRaf);
                pickerPlayRaf = null;
            }
            if (wavesurferInstance) {
                wavesurferInstance.pause();
            }
        }

        const overlay = document.createElement('div');
        overlay.className = 'seg-picker-overlay';
        const modal = document.createElement('div');
        modal.className = 'seg-picker-modal';
        const header = document.createElement('div');
        header.className = 'seg-picker-header';
        header.innerHTML = `<span>Select segment for <span style="color:${speaker.hue}">${speaker.name}</span></span>`;
        const closeBtn = document.createElement('button');
        closeBtn.className = 'seg-picker-close';
        closeBtn.innerHTML = '<span class="icon icon-close" style="width:12px;height:12px;"></span>';
        closeBtn.onclick = () => { stopPickerPlay(); overlay.remove(); };
        header.appendChild(closeBtn);
        modal.appendChild(header);

        const list = document.createElement('div');
        list.className = 'seg-picker-list';

        segs.forEach((seg, si) => {
            const segSpeaker = this.activeProject.getSpeaker(seg.speaker);
            const color  = segSpeaker.hue;
            const name   = this.speakerDisplayName(seg.speaker);
            const isSelf = seg.speaker === speaker.id; // dim non-matching speaker rows

            const item = document.createElement('div');
            item.className = 'seg-picker-item';
            if (!isSelf) item.style.opacity = '0.55';

            // Speaker dot
            const dot = document.createElement('span');
            dot.className = 'seg-picker-speaker-dot';
            dot.style.background = color;

            // Speaker name
            const nameSpan = document.createElement('span');
            nameSpan.className = 'seg-picker-speaker-name';
            nameSpan.style.color = color;
            nameSpan.textContent = name;

            // Time
            const timeSpan = document.createElement('span');
            timeSpan.className = 'seg-picker-time';
            timeSpan.textContent = formatTimeMs(seg.start) + ' – ' + formatTimeMs(seg.end);

            // Text
            const textSpan = document.createElement('span');
            textSpan.className = 'seg-picker-text';
            textSpan.textContent = seg.text;

            // Play button — previews the segment in the main waveform
            const playBtn = document.createElement('button');
            playBtn.className = 'seg-picker-play';
            playBtn.textContent = '▶';
            playBtn.title = 'Preview segment';

            playBtn.onclick = (e) => {
                e.stopPropagation();

                if (pickerPlayBtn === playBtn) {
                    stopPickerPlay();
                    return;
                }
                // toggle off
                stopPickerPlay();

                if (!wavesurferInstance || !totalDuration) {
                    return;
                }

                wavesurferInstance.seekTo(seg.start / totalDuration);
                wavesurferInstance.play();

                playBtn.textContent = '⏹';
                playBtn.classList.add('playing');
                pickerPlayBtn = playBtn;

                // rAF loop to stop playback when the segment ends
                /** Polls the wavesurfer position each frame and stops when the segment ends. */
                function watchEnd() {
                    const t = wavesurferInstance.getCurrentTime();

                    if (t >= seg.end) {
                        stopPickerPlay();
                        return;
                    }
                    // if the segment has not reached its end, wait for the next frame
                    pickerPlayRaf = requestAnimationFrame(watchEnd);
                }

                pickerPlayRaf = requestAnimationFrame(watchEnd);
            };

            item.appendChild(dot);
            item.appendChild(nameSpan);
            item.appendChild(timeSpan);
            item.appendChild(textSpan);
            item.appendChild(playBtn);

            // Click row (not play btn) to select segment as the voice sample
            item.onclick = async () => {
                if (!isSelf) {
                    // Confirm using a different speaker's segment
                    new ConfirmDialog(`This segment belongs to ${name}. Use it as a sample for ${this.speakerDisplayName(speaker.id)}?`, {
                        onConfirm: async () => {
                            stopPickerPlay();
                            overlay.remove();
                            await this.sliceSegmentAsSample(speaker.id, seg);
                        }
                    });
                    return;
                }
                stopPickerPlay();
                overlay.remove();
                await this.sliceSegmentAsSample(speaker.id, seg);
            };

            list.appendChild(item);
        });

        modal.appendChild(list);
        overlay.appendChild(modal);
        let _mouseDownOnOverlay = false;
        overlay.addEventListener('mousedown', (e) => { _mouseDownOnOverlay = e.target === overlay; });
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay && _mouseDownOnOverlay) {
                stopPickerPlay();
                overlay.remove();
            }
        });

        document.body.appendChild(overlay);
    }

    /**
    * Extracts the audio for a single transcript segment from the main track and
    * stores it as a speaker voice sample. Uses the full decoded audio buffer to
    * avoid re-fetching; slices by sample index rather than time.
    * Only channel 0 is used (mono sample).
    * @param {string} id - speaker id
    * @param {{start: number, end: number}} seg - segment with start/end in seconds
    */
    async sliceSegmentAsSample(id, seg) {
        const wavesurferInstance = this.workspace.wavesurferInstance();
        const totalDuration = this.activeProject.waveform().duration;;;

        if (!wavesurferInstance || !totalDuration) return;
        // WaveSurfer doesn't expose the decoded buffer directly after the two-pass load,
        // so we re-fetch the audio from the URL and decode it ourselves.
        const wrapper = wavesurferInstance.getWrapper ? wavesurferInstance.getWrapper() : null;
        const media = wrapper ? wrapper.closest('[data-wavesurfer]') : null;
        // Try to get the audio element's src; fall back to currentStreamUrl
        const audioEl = document.querySelector('#waveform audio, #waveform media') || wavesurferInstance.getMediaElement?.();
        const loadedUrl = audioEl?.src || this.activeProject.waveform()?.url;
        if (!loadedUrl) {
            alert('Audio not loaded yet.');
            return;
        }

        try {
            const resp = await fetch(loadedUrl);
            const fullBuf = await resp.arrayBuffer();
            // Decode full audio then slice the channel data
            const actx = new (window.AudioContext || window.webkitAudioContext)();
            const fullAudio = await actx.decodeAudioData(fullBuf);
            const sampleRate = fullAudio.sampleRate;
            const startSample = Math.floor(seg.start * sampleRate);
            const endSample   = Math.min(Math.ceil(seg.end * sampleRate), fullAudio.length);
            const segLen = endSample - startSample;
            const slicedBuffer = actx.createBuffer(1, segLen, sampleRate);
            const srcData = fullAudio.getChannelData(0);
            slicedBuffer.copyToChannel(srcData.slice(startSample, endSample), 0);

            // Build 120-bar peaks for the mini waveform
            const numBars = 120;
            const blockSize = Math.max(1, Math.floor(segLen / numBars));
            const peaks = [];
            const slicedData = slicedBuffer.getChannelData(0);
            for (let i = 0; i < numBars; i++) {
                let max = 0;
                for (let j = 0; j < blockSize; j++) {
                    max = Math.max(max, Math.abs(slicedData[i * blockSize + j] || 0));
                }
                peaks.push(max);
          }

          // Re-render via OfflineAudioContext to ensure the buffer is fully rendered
          // (not strictly necessary here but ensures compatibility)
          const offCtx = new OfflineAudioContext(1, segLen, sampleRate);
          const bufSrc = offCtx.createBufferSource();
          bufSrc.buffer = slicedBuffer;
          bufSrc.connect(offCtx.destination);
          bufSrc.start();
          await offCtx.startRendering();
          actx.close();

          // Encode the sliced buffer as a WAV blob so it can be uploaded to the server
          const wavBlob = encodeMonoWav(slicedBuffer.getChannelData(0), slicedBuffer.sampleRate);
          const blobUrl = URL.createObjectURL(wavBlob);
          this.setSpeakerSample(id, slicedBuffer, blobUrl, peaks);
        } catch(err) {
          console.error('Slice error:', err);
          alert('Could not extract segment audio.');
        }
    }


    /**
    * Builds and shows the speaker reassignment/deletion dialog.
    * When affectedCount > 0 a dropdown lets the user choose a target speaker.
    * @param {object} speaker - speaker being deleted
    * @param {object} speakers - all speakers in the project
    * @param {number} affectedCount - number of segments assigned to the speaker
    * @param {object} callbacks - callback functions for dialog actions
    * @param {object} callbacks.onConfirm - called with the target speaker id (or undefined)
    */
    #buildReassignDialog(speaker, speakers, affectedCount, { onConfirm }) {
        let warningText = "";
        let confirmText = "";
        let injection = null;

        const headerText = `Delete speaker <span style="color:${speaker.hue}">${speaker.name}</span>`

        const others = Object.keys(speakers).filter(id => id !== speaker.id);

        if(affectedCount > 0) {
            warningText = `${affectedCount} segment${affectedCount !== 1 ? 's are' : ' is'} assigned to this speaker. Reassign them to:`;
            confirmText = 'Reassign & Delete'

            // Speaker select dropdown
            injection = document.createElement('select');
            injection.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-body);background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:0.4rem 0.6rem;outline:none;cursor:pointer;';
            others.forEach(oid => {
                const opt = document.createElement('option');
                opt.value = oid;
                opt.textContent = this.speakerDisplayName(oid);
                injection.appendChild(opt);
            });
        } else {
            warningText = `${speaker.name} has no assigned segments — safe to remove.`;
            confirmText = 'Delete'
        }

        const reassignDialog = new ConfirmDialog(headerText, {
            onConfirm: () => {
                if (affectedCount > 0) { onConfirm(injection.value); }
                else { onConfirm(); }
            }
        }, warningText, confirmText, "Cancel", injection);



//        // Build reassignment dialog
//        const others = Object.keys(speakers).filter(id => id !== speaker.id);
//        const overlay = document.createElement('div');
//        overlay.className = 'confirm-dialog-overlay';
//
//        const modal = document.createElement('div');
//        modal.className = 'confirm-dialog-modal';
//        modal.style.cssText = 'max-height:none;width:380px;padding:0;';
//
//        const header = document.createElement('div');
//        header.className = 'confirm-dialog-header';
//        header.innerHTML = `<span>Delete speaker <span style="color:${speaker.hue}">${name}</span></span>`;
//        modal.appendChild(header);
//
//        const body = document.createElement('div');
//        body.style.cssText = 'padding:1rem 1.25rem;display:flex;flex-direction:column;gap:0.85rem;';
//
//        const warning = document.createElement('div');
//        warning.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-caption);color:var(--text);line-height:1.6;';

//        if(affectedCount > 0) {
//            warning.textContent = `${affectedCount} segment${affectedCount !== 1 ? 's are' : ' is'} assigned to this speaker. Reassign them to:`;
//        } else {
//            warning.textContent = `${speaker.name} has no assigned segments — safe to remove.`;
//        }
//
//        body.appendChild(warning);
//
//        let select = null;
//        if (affectedCount > 0) {
//            // Speaker select dropdown
//            select = document.createElement('select');
//            select.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-body);background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:0.4rem 0.6rem;outline:none;cursor:pointer;';
//            others.forEach(oid => {
//                const opt = document.createElement('option');
//                opt.value = oid;
//                opt.textContent = this.speakerDisplayName(oid);
//                select.appendChild(opt);
//            });
//            body.appendChild(select);
//        }

//        const actions = document.createElement('div');
//        actions.style.cssText = 'display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.25rem;';
//
//        const cancelBtn = document.createElement('button');
//        cancelBtn.className = 'sample-btn';
//        cancelBtn.textContent = 'Cancel';
//        cancelBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
//        cancelBtn.onclick = () => overlay.remove();
//
//        const confirmBtn = document.createElement('button');
//        confirmBtn.className = 'sample-btn';

//        if(affectedCount > 0) {
//            confirmBtn.textContent = 'Reassign & Delete'
//        } else {
//            confirmBtn.textContent = 'Delete'
//        }

//        confirmBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);border-color:rgba(255,119,119,0.4);color:#ff9999;';
//        confirmBtn.onmouseenter = () => { confirmBtn.style.background = 'rgba(255,119,119,0.1)'; confirmBtn.style.borderColor = '#ff7777'; };
//        confirmBtn.onmouseleave = () => { confirmBtn.style.background = ''; confirmBtn.style.borderColor = 'rgba(255,119,119,0.4)'; };

//        confirmBtn.onclick = () => {
//            if (select) {
//                onConfirm(select.value);
//            } else {
//                onConfirm();
//            }
//            overlay.remove();
//        };

//        actions.appendChild(cancelBtn);
//        actions.appendChild(confirmBtn);
//        body.appendChild(actions);
//        modal.appendChild(body);
//        overlay.appendChild(modal);
//        overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
//        document.body.appendChild(overlay);

    }

    /**
    * Deletes a speaker. If any transcript segments are assigned to this speaker,
    * shows a reassignment modal prompting the user to pick another speaker before
    * deletion. If no segments are affected, shows a simpler confirmation modal.
    * Prevents deletion when only one speaker exists.
    * @param {string} id - speaker id to remove
    */
    deleteSpeaker(id) {
        const speakers = this.activeProject.speakers();
        if (Object.keys(speakers).length <= 1) return; // must keep at least one speaker

        const speaker = speakers[id];
        const segs = this.activeProject.transcript().segments;
        const affectedIndices = segs.map((s, i) => s.speaker === id ? i : -1).filter(i => i >= 0);
        const affectedCount = affectedIndices.length;

        if (affectedCount > 0) {
            this.#buildReassignDialog(speaker, speakers, affectedCount, {
                onConfirm: (targetId) => {
                    const deletedSpeaker = { id: speaker.id, name: speaker.name, hue: speaker.hue, sample: speaker.sample };
                    this.activeProject.reassignSegments(speaker.id, targetId);
                    this.activeProject.removeSpeaker(speaker.id);
                    const transcript = this.activeProject.transcript();
                    this.workspace.history.push({
                        label: 'Delete speaker', dirtyFlags: ['speakers', 'transcript'],
                        undo: () => {
                            this.activeProject.speakers()[deletedSpeaker.id] = deletedSpeaker;
                            affectedIndices.forEach(i => { segs[i].speaker = deletedSpeaker.id; });
                            transcript.buildTranscript();
                        },
                        redo: () => {
                            affectedIndices.forEach(i => { segs[i].speaker = targetId; });
                            transcript.buildTranscript();
                            delete this.activeProject.speakers()[deletedSpeaker.id];
                        },
                    });
                    this.workspace._updateUndoRedoButtons();
                }
            });
        } else {
            this.#buildReassignDialog(speaker, speakers, 0, {
                onConfirm: () => {
                    const deletedSpeaker = { id: speaker.id, name: speaker.name, hue: speaker.hue, sample: speaker.sample };
                    this.activeProject.removeSpeaker(speaker.id);
                    this.workspace.history.push({
                        label: 'Delete speaker', dirtyFlags: ['speakers'],
                        undo: () => {
                            this.activeProject.speakers()[deletedSpeaker.id] = deletedSpeaker;
                        },
                        redo: () => {
                            delete this.activeProject.speakers()[deletedSpeaker.id];
                        },
                    });
                    this.workspace._updateUndoRedoButtons();
                }
            });
        }
    }

    /** Re-renders the speakers table from the active project's speaker data. */
    renderSpeakersPanel() {
        const readOnly = this.workspace.isReadOnly();
        this.addSpeakerBtn.style.display = readOnly ? 'none' : '';

        if (!this.activeProject || !Object.keys(this.activeProject.speakers()).length) {
            this.speakersEmpty.style.display = 'flex';
            this.speakersTable.style.display = 'none';
            return;
        }

        // If there are speakers, disable the "empty" element and enable the table
        this.speakersEmpty.style.display = 'none';
        this.speakersTable.style.display = 'table';
        // Clear the table
        this.speakersTableBody.innerHTML = '';

        // build a speaker row for each speaker, ordered by first appearance in
        // the transcript (if one exists) or alphabetically by speaker id otherwise
        const speakers = Object.values(this.activeProject.speakers());
        const segments = this.activeProject.transcript()?.segments;
        if (segments && segments.length) {
            const firstIndex = {};
            segments.forEach((seg, i) => {
                if (!(seg.speaker in firstIndex)) firstIndex[seg.speaker] = i;
            });
            speakers.sort((a, b) => {
                const ia = firstIndex[a.id] ?? Infinity;
                const ib = firstIndex[b.id] ?? Infinity;
                if (ia !== ib) return ia - ib;
                return a.id.localeCompare(b.id);
            });
        } else {
            speakers.sort((a, b) => a.id.localeCompare(b.id));
        }
        speakers.forEach((speaker) => this.buildSpeakerRow(speaker));
    }


    /**
    * Builds a table row for a speaker and appends it to the speakers table body.
    * @param {object} speaker - speaker object with id, name, hue, and sample
    */
    buildSpeakerRow(speaker) {
        const tr = document.createElement('tr');

        // Hue display and selection cell
        const tdColor = document.createElement('td');
        tdColor.style.width = '2.5rem';
        const swatch = document.createElement('span');
        swatch.className = 'speaker-row-swatch';
        swatch.style.background = speaker.hue;
        if (!this.workspace.isReadOnly()) {
            swatch.title = 'Change color';
            swatch.addEventListener('click', (e) => {
                e.stopPropagation();
                this.openHuePicker(speaker, swatch);
            });
        }
        tdColor.appendChild(swatch);
        tr.appendChild(tdColor);

        // Name cell — editable
        const tdName = document.createElement('td');
        const nameSpan = this.makeSpeakerNameSpan(speaker)
        tdName.appendChild(nameSpan);
        tr.appendChild(tdName);

        // Raw ID cell
        const tdId = document.createElement('td');
        tdId.style.color = 'var(--muted)';
        tdId.textContent = speaker.id;
        tr.appendChild(tdId);

        // Sample cell
        const tdSample = this.buildSpeakerSampleCell(speaker);
        tr.appendChild(tdSample);

        // Delete cell — hidden in read-only mode
        const tdDel = document.createElement('td');
        tdDel.style.width = '1.5rem';
        if (!this.workspace.isReadOnly()) {
            const delBtn = document.createElement('button');
            delBtn.className = 'speaker-del-btn';
            delBtn.innerHTML = '<span class="icon icon-close" style="width:11px;height:11px;"></span>';
            delBtn.title = 'Delete speaker';
            delBtn.disabled = Object.keys(this.activeProject.speakers()).length <= 1;
            delBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                this.deleteSpeaker(speaker.id);
            });
            tdDel.appendChild(delBtn);
        }
        tr.appendChild(tdDel);

        this.speakersTableBody.appendChild(tr);
    }

    /**
    * Creates a styled, clickable speaker name span. Clicking it enters inline
    * edit mode; hovering it highlights that speaker's regions on the waveform.
    * @param {object|string} speaker - the speaker object, or a speaker id string to look up
    * @returns {HTMLElement}
    */
    makeSpeakerNameSpan(speaker) {
        if (!speaker) {
            console.error("Speaker cannot be null.");
            return;
        }

        // if speaker is passed as an id, get the speaker by its id
        if (typeof speaker === 'string') {
            speaker = this.activeProject.speakers()[speaker];
        }

        const span = document.createElement('span');
        span.className = 'speaker-name';
        span.dataset.speakerId = speaker.id;
        span.style.color = speaker.hue;
        span.textContent = speaker.name;
        if (!this.workspace.isReadOnly()) {
            span.title = 'Click to rename';
            span.addEventListener('click', () => this.makeSpeakerEditable(span, speaker));
        }
        span.addEventListener('mouseenter', () => this.onSpeakerHover(speaker.id));
        span.addEventListener('mouseleave', () => this.onSpeakerHover(-1));
        return span;
    }

    /**
    * Builds the sample cell for a speaker row. If a sample exists it shows
    * the waveform with play/remove controls; otherwise it shows upload/record/segment buttons.
    * @param {object} speaker - the speaker object to build the cell for
    * @returns {HTMLTableCellElement}
    */
    buildSpeakerSampleCell(speaker) {
        const tdSample = document.createElement('td');
        tdSample.className = 'sample-cell';
        const sample = speaker.sample;
        const readOnly = this.workspace.isReadOnly();

        if (sample && Object.keys(speaker.sample).length) {
            // Show waveform + play button + remove (remove hidden in read-only)
            const wrap = document.createElement('div');
            wrap.className = 'sample-waveform-wrap';

            const playBtn = document.createElement('button');
            playBtn.className = 'sample-play-btn';
            playBtn.textContent = '▶';
            playBtn.title = 'Play sample';

            const canvasWrap = document.createElement('div');
            canvasWrap.className = 'sample-canvas-wrap';
            const canvas = document.createElement('canvas');
            const progressLine = document.createElement('div');
            progressLine.className = 'sample-progress-line';
            canvasWrap.appendChild(canvas);
            canvasWrap.appendChild(progressLine);

            wrap.appendChild(playBtn);
            wrap.appendChild(canvasWrap);

            if (!readOnly) {
                const removeBtn = document.createElement('button');
                removeBtn.className = 'sample-remove-btn';
                removeBtn.innerHTML = '<span class="icon icon-close" style="width:11px;height:11px;"></span>';
                removeBtn.title = 'Remove sample';
                removeBtn.onclick = () => this.removeSpeakerSample(speaker.id);
                wrap.appendChild(removeBtn);
            }

            tdSample.appendChild(wrap);

            // Draw waveform after layout
            requestAnimationFrame(() => {
              this.drawSampleWaveform(canvas, sample.peaks, 0, speaker.hue);
            });

            playBtn.onclick = () => this.playSampleAudio(speaker, playBtn, canvas, sample.peaks, progressLine);

        } else if (!readOnly) {
            // Show add options only when not read-only
            const empty = document.createElement('div');
            empty.className = 'sample-empty';

            const uploadBtn = document.createElement('button');
            uploadBtn.className = 'sample-btn';
            uploadBtn.innerHTML = '<span class="icon icon-arrow-up" style="width:11px;height:11px;"></span> Upload';
            uploadBtn.onclick = () => this.openSampleUpload(speaker.id);

            const recBtn = document.createElement('button');
            recBtn.className = 'sample-btn';
            recBtn.textContent = '⏺ Rec';
            recBtn.onclick = () => this.startRecording(speaker.id, recBtn);

            const segBtn = document.createElement('button');
            segBtn.className = 'sample-btn';
            segBtn.textContent = '✂ Segment';
            segBtn.title = 'Use audio from a transcript segment';
            segBtn.onclick = () => this.openSegPicker(speaker);

            empty.appendChild(uploadBtn);
            empty.appendChild(recBtn);
            empty.appendChild(segBtn);
            tdSample.appendChild(empty);
        }

        return tdSample;
    }

    /**
    * Returns the user-facing display name for a speaker, falling back to the
    * raw CSV id if no rename has been applied.
    * @param {string} id - speaker id to look up
    * @returns {string}
    */
    speakerDisplayName(id) {
        return this.activeProject.getSpeaker(id).name || id;
    }

    /**
    * Renames a speaker: updates liveNameMap, marks the transcript dirty, and
    * live-patches all in-DOM speaker name spans so the page doesn't need a full
    * re-render.
    * @param {object} speaker - the speaker object to rename
    * @param {string} newName - desired display name; reset to id if blank
    */
    renameSpeaker(speaker, newName) {
        newName = newName.trim();
        // If no new name is provided, set it to the di
        if (!newName) {
            newName = speaker.id;
        }
        const oldName = speaker.name;
        // Set the new name
        speaker.name = newName;
        // Set the project to be dirty
        this.activeProject.markSpeakersDirty();
        // Update all in-DOM speaker name spans without re-rendering the whole transcript
        document.querySelectorAll(`.speaker-name[data-speaker-id="${speaker.id}"]`).forEach(el => {
          el.textContent = newName;
        });

        if (oldName !== newName) {
            this.workspace.history.push({
                label: 'Rename speaker', dirtyFlags: ['speakers'],
                undo: () => { speaker.name = oldName; },
                redo: () => { speaker.name = newName; },
            });
            this.workspace._updateUndoRedoButtons();
        }

        this.renderSpeakersPanel();
    }

    /**
    * Replaces a speaker name span with a text input for inline editing.
    * Commits on Enter or blur; cancels (restores span) on Escape.
    * @param {HTMLElement} spanEl - the .speaker-name span to replace
    * @param {object} speaker - the speaker object being renamed
    */
    makeSpeakerEditable(spanEl, speaker) {
        const input = document.createElement('input');
        input.type = 'text';
        input.className = 'speaker-name-input';
        input.value = speaker.name;
        input.style.color = speaker.hue;
        spanEl.replaceWith(input);
        input.focus();
        input.select();

        const commit = () => {
            this.renameSpeaker(speaker, input.value);
            // Re-create the span in place of the input
            input.replaceWith(this.makeSpeakerNameSpan(speaker));
        }

        const revert = () => {
            input.replaceWith(spanEl);
        }

        input.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') {
                e.preventDefault();
                commit();
            } else if (e.key === 'Escape') {
                revert();
            }
            e.stopPropagation();
        });

        input.addEventListener('blur', commit);
    }

}