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">▶</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>«</button>
<button class="pw-nav" data-nav="prev-seg" title="Previous segment" disabled>‹</button>
<button class="pw-nav" data-nav="next-seg" title="Next segment" disabled>›</button>
<button class="pw-nav" data-nav="next-para" title="Next paragraph" disabled>»</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">🔊</button>
<div class="pw-vol-popup" hidden>
<button class="pw-mute-btn" title="Mute / Unmute">🔊</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) ? '🔇' : vol < 0.4 ? '🔈' : '🔊';
this.volIconBtn.innerHTML = icon;
this.muteBtn.innerHTML = (vol === 0 || this._muted) ? '🔇' : '🔊';
}
/**
* 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 = '⏸'; });
this.wavesurferInstance.on('pause', () => { this.playBtn.innerHTML = '▶'; });
this.wavesurferInstance.on('finish', () => { this.playBtn.innerHTML = '▶'; 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">🔗</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');
});