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.
* @param {Function} callbacks.onUserSeek - Called with time in seconds when the user seeks manually.
*/
constructor(mountEl, ctrl, { onRegionHover, onRegionSelect, onRegionActivate, onUserSeek }) {
this.mountEl = mountEl;
this.ctrl = ctrl;
this.onRegionHover = onRegionHover ?? (() => {});
this.onRegionSelect = onRegionSelect ?? (() => {});
this.onRegionActivate = onRegionActivate ?? (() => {});
this.onUserSeek = onUserSeek ?? (() => {});
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.sectionBreakLineEls = [];
this.sectionBreakLabelEls = [];
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.waveformAreaEl = m.querySelector('.pw-waveform-area');
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) {
const idx = this.#regionAtTime(t);
this.onRegionActivate(idx);
this.onUserSeek(idx);
}
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 });
this.renderSectionBreakLines();
});
}
/**
* 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;
// Skip over off-the-record paragraphs during playback
if (this.activeProject?.hasTranscript) {
const otrPara = this.activeProject.transcript().paragraphs.find(
p => p.off_the_record && t >= p.segments[0].start && t < p.segments[p.segments.length - 1].end
);
if (otrPara) {
const skipTo = otrPara.segments[otrPara.segments.length - 1].end;
this.wavesurferInstance.media.currentTime = Math.max(0, skipTo);
this._rafPending = false;
return;
}
}
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.media.currentTime = Math.max(0, segs[target].start);
}
/**
* Seeks to the start of the next segment.
*/
#navNextSeg() {
if (!this.wavesurferInstance) return;
const segs = this.activeProject.transcript().segments;
const next = this.#segIdxAtOrBefore(this.#currentTime()) + 1;
if (next < segs.length) this.wavesurferInstance.media.currentTime = Math.max(0, segs[next].start);
}
/**
* 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) 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.media.currentTime = Math.max(0, paras[target].segments[0].start);
}
/**
* Seeks to the start of the next paragraph.
*/
#navNextPara() {
if (!this.wavesurferInstance) return;
const paras = this.activeProject.transcript().paragraphs;
const next = this.#paraIdxAtOrBefore(this.#currentTime()) + 1;
if (next < paras.length) this.wavesurferInstance.media.currentTime = Math.max(0, paras[next].segments[0].start);
}
/**
* 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) {
const seg = this.activeProject?.transcript().segments[idx];
if (seg && this.wavesurferInstance?.media) {
this.wavesurferInstance.media.currentTime = Math.max(0, seg.start);
}
}
this.drawRegions();
}
/**
* Creates (or recreates) the section-break line and label DOM elements inside
* the waveform area. Called once per load after the waveform is ready.
*/
renderSectionBreakLines() {
this.sectionBreakLineEls.forEach(el => el.remove());
this.sectionBreakLabelEls.forEach(el => el.remove());
this.sectionBreakLineEls = [];
this.sectionBreakLabelEls = [];
const breaks = [...(this.activeProject?.transcript()?.sectionBreaks ?? [])]
.sort((a, b) => a.beforeSegStart - b.beforeSegStart);
for (let i = 0; i < breaks.length; i++) {
const t = breaks[i].beforeSegStart;
const line = document.createElement('div');
line.className = 'pw-section-line';
this.waveformAreaEl.appendChild(line);
this.sectionBreakLineEls.push(line);
const label = document.createElement('div');
label.className = 'pw-section-label';
label.addEventListener('click', () => {
if (this.wavesurferInstance?.media) {
this.wavesurferInstance.media.currentTime = Math.max(0, t);
}
});
this.waveformAreaEl.appendChild(label);
this.sectionBreakLabelEls.push(label);
}
this.positionSectionBreakLines();
}
/**
* Updates the left position of each section-break line based on current scroll and zoom.
* Called from drawRegions() on every redraw.
*/
positionSectionBreakLines() {
const rawBreaks = this.activeProject?.transcript()?.sectionBreaks ?? [];
if (!rawBreaks.length && !this.sectionBreakLineEls.length) return;
const breaks = [...rawBreaks].sort((a, b) => a.beforeSegStart - b.beforeSegStart);
const scrollEl = this.#getScrollEl();
const laneW = this.regionLane.clientWidth;
const totalW = scrollEl ? scrollEl.scrollWidth : laneW;
const scrollX = scrollEl ? scrollEl.scrollLeft : 0;
// Horizontal offset of the region lane within the waveform area
const areaRect = this.waveformAreaEl.getBoundingClientRect();
const laneRect = this.regionLane.getBoundingClientRect();
const laneLeft = laneRect.left - areaRect.left;
breaks.forEach((sb, i) => {
const lineEl = this.sectionBreakLineEls[i];
const labelEl = this.sectionBreakLabelEls[i];
if (!lineEl || !labelEl) return;
const x = (sb.beforeSegStart / this.totalDuration) * totalW - scrollX;
const visible = x >= 0 && x <= laneW && this.totalDuration > 0;
lineEl.style.display = visible ? '' : 'none';
labelEl.style.display = visible ? '' : 'none';
if (visible) {
lineEl.style.left = (x + laneLeft) + 'px';
const num = i + 1;
const text = sb.name ? `${num}. ${sb.name}` : `${num}.`;
labelEl.textContent = text;
const lw = labelEl.offsetWidth;
const clamped = Math.max(lw / 2, Math.min(laneW - lw / 2, x));
labelEl.style.left = (clamped + laneLeft) + 'px';
}
});
}
/**
* 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 isOtr = !!para.off_the_record;
const spkHex = isOtr ? '#888888' : (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 = !isOtr && currentT >= seg.start && currentT < seg.end;
const isSelected = !isOtr && segIdx === this.ctrl.selectedSegmentIdx;
const isHovered = !isOtr && 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 = isOtr ? 0.3 : 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 (suppressed for OTR paragraphs)
if (!isOtr) {
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();
}
}
}
}
this.positionSectionBreakLines();
}
}
// ── 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;
/** @type {Array<{number: number, name: string, beforeSegStart: number, el: HTMLElement}>} */
this.sections = [];
}
/**
* 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.sections = [];
this.root.innerHTML = '';
if (!project.hasTranscript) {
this.root.innerHTML = '<div class="pt-empty">No transcript available.</div>';
return;
}
const { segments } = project.transcript();
const speakers = project.speakers();
const { items } = project.transcript().toRenderSequence(project.notes ?? []);
for (const item of items) {
if (item.type === 'sectionBreak') {
const breakEl = document.createElement('div');
breakEl.className = 'pt-section-break';
breakEl.title = 'Click to seek to this section';
breakEl.addEventListener('click', () => this.ctrl.seekTo(item.beforeSegStart));
const numEl = document.createElement('span');
numEl.className = 'pt-section-break-num';
numEl.textContent = `§${item.number}`;
breakEl.appendChild(numEl);
if (item.name) {
const nameEl = document.createElement('span');
nameEl.className = 'pt-section-break-name';
nameEl.textContent = item.name;
breakEl.appendChild(nameEl);
}
this.root.appendChild(breakEl);
this.sections.push({ number: item.number, name: item.name ?? '', beforeSegStart: item.beforeSegStart, el: breakEl });
} else if (item.type === 'speakerBlock') {
const spk = speakers[item.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 ?? item.speaker;
spkEl.append(dot, nameEl);
blockEl.appendChild(spkEl);
// Paragraphs
item.paragraphs.forEach(para => {
const paraEl = document.createElement('p');
paraEl.className = 'pt-paragraph';
if (para.off_the_record) {
paraEl.classList.add('pt-paragraph--otr');
const placeholder = document.createElement('span');
placeholder.className = 'pt-otr-placeholder';
const otrSegs = para.segments;
const otrDuration = otrSegs?.length
? otrSegs[otrSegs.length - 1].end - otrSegs[0].start
: (para.end ?? 0) - (para.start ?? 0);
const otrMins = Math.floor(otrDuration / 60);
const otrSecs = Math.round(otrDuration % 60);
const otrLen = otrMins > 0
? `${otrMins}m ${otrSecs}s`
: `${otrSecs}s`;
placeholder.textContent = `Off The Record (${otrLen})`;
paraEl.appendChild(placeholder);
} else {
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);
} else if (item.type === 'note') {
const noteEl = document.createElement('div');
noteEl.className = `pt-note pt-note--${item.noteType.replace(/_/g, '-')}`;
if (item.noteType === 'stage_direction') {
noteEl.textContent = `(${item.text})`;
} else if (item.noteType === 'speech_label') {
noteEl.textContent = `[${item.text.toUpperCase()}]`;
} else {
noteEl.textContent = `\u2014 ${item.text} \u2014`;
}
this.root.appendChild(noteEl);
}
}
}
/**
* 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;
this._sectionNav = 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, transcript, and optional section nav panels.
* @param {PresentationWaveform} waveformPanel - The waveform panel instance to wire up.
* @param {PresentationTranscript} transcriptPanel - The transcript panel instance to wire up.
* @param {PresentationSectionNav|null} [sectionNav] - Optional section navigation panel.
*/
setPanels(waveformPanel, transcriptPanel, sectionNav = null) {
this._waveform = waveformPanel;
this._transcript = transcriptPanel;
this._sectionNav = sectionNav;
}
/**
* 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 user manually seeks (clicks timeline).
* Scrolls the transcript to bring the segment into view below the sticky waveform.
* @param {number} idx - Segment index at the seeked position, or -1.
*/
onUserSeek(idx) {
this._transcript?.scrollToSegment(idx);
}
/**
* 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);
if (idx >= 0 && this._sectionNav) {
const seg = this.activeProject?.transcript()?.segments[idx];
if (seg) this._sectionNav.setActiveByTime(seg.start);
}
}
/**
* 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);
this._sectionNav?.setActiveByTime(time);
}
}
}
// ── PresentationSectionNav ────────────────────────────────────────────────────
// Floating section navigator for presentation mode. Renders clickable section
// items in a sticky sidebar and highlights the active section as the playhead
// advances. On mobile it is collapsible via a toggle button.
/**
* Section navigator panel for presentation mode.
*/
class PresentationSectionNav {
/**
* @param {HTMLElement} navEl - The root <nav> element.
* @param {HTMLElement} listEl - The list container inside the nav.
* @param {HTMLElement} toggleEl - The mobile toggle button.
*/
constructor(navEl, listEl, toggleEl) {
this.navEl = navEl;
this.listEl = listEl;
this._sections = []; // [{number, name, beforeSegStart, el}]
this._navItemEls = [];
this._activeIdx = -1;
this._getOffset = () => 0; // returns px to leave above the target element
this._onNavigate = null; // called with beforeSegStart when a section is clicked
toggleEl?.addEventListener('click', () => navEl.classList.toggle('open'));
}
/**
* Sets a function that returns the current scroll offset (in px) to leave
* above the section element — used to keep it visible below the sticky waveform.
* @param {function(): number} fn - Returns the pixel offset to leave above the section element.
*/
setScrollOffset(fn) { this._getOffset = fn; }
/**
* Sets a callback invoked with the section's start time when a nav item is clicked.
* @param {function(number): void} fn - Callback receiving the section's start time in seconds.
*/
setOnNavigate(fn) { this._onNavigate = fn; }
/**
* Registers section elements from the rendered transcript and builds the nav list.
* @param {Array<{number: number, name: string, beforeSegStart: number, el: HTMLElement}>} sections - Section descriptors derived from the rendered transcript.
*/
setSections(sections) {
this._sections = [...sections].sort((a, b) => a.beforeSegStart - b.beforeSegStart);
this._navItemEls = [];
this._activeIdx = -1;
this.listEl.innerHTML = '';
if (!this._sections.length) {
this.navEl.hidden = true;
return;
}
this.navEl.hidden = false;
this._sections.forEach((sec, i) => {
const item = document.createElement('button');
item.className = 'pres-section-nav-item';
const numEl = document.createElement('span');
numEl.className = 'pres-section-nav-num';
numEl.textContent = `§${sec.number}`;
item.appendChild(numEl);
if (sec.name) {
const nameEl = document.createElement('span');
nameEl.className = 'pres-section-nav-name';
nameEl.textContent = sec.name;
item.appendChild(nameEl);
}
item.addEventListener('click', () => {
this._scrollToEl(sec.el);
this._onNavigate?.(sec.beforeSegStart);
if (window.innerWidth <= 640) this.navEl.classList.remove('open');
});
this.listEl.appendChild(item);
this._navItemEls.push(item);
});
}
/**
* Scrolls the page so that `el` appears just below the sticky waveform.
* @param {HTMLElement} el - Element to scroll into view.
*/
_scrollToEl(el) {
if (!el) return;
el.style.scrollMarginTop = this._getOffset() + 'px';
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/**
* Updates the highlighted section based on playhead time.
* @param {number} t - Current playback time in seconds.
*/
setActiveByTime(t) {
let activeIdx = -1;
for (let i = 0; i < this._sections.length; i++) {
if (this._sections[i].beforeSegStart <= t + 0.001) activeIdx = i;
else break;
}
if (activeIdx === this._activeIdx) return;
if (this._activeIdx >= 0) this._navItemEls[this._activeIdx]?.classList.remove('active');
if (activeIdx >= 0) this._navItemEls[activeIdx]?.classList.add('active');
this._activeIdx = activeIdx;
}
}
// ── 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);
if (pdata.sectionBreaks?.length) {
t.sectionBreaks = pdata.sectionBreaks.map(b => ({ ...b }));
}
proj.server.transcript = t;
proj.local.transcript = t;
proj.hasTranscript = true;
// Auto-create any speakers referenced in the transcript but absent from the DB.
// This covers projects that were transcribed but never opened in the workspace
// (so speakers were never saved), which would otherwise render as grey with no name.
const uniqueSpeakers = [...new Set(segments.map(s => s.speaker))];
uniqueSpeakers.forEach(speakerId => {
if (!proj.server.speakers.hasOwnProperty(speakerId)) {
proj.addSpeaker(speakerId, speakerId, null, {}, false);
}
});
proj.local.speakers = { ...proj.server.speakers };
}
// 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;
// Notes (only public ones are shown in presentation)
if (Array.isArray(pdata.notes)) {
proj.notes = pdata.notes.filter(n => n.public);
}
// 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, transcriptResp, 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' }),
fetch(`${base}/api/projects/${projectId}/annotations`, { headers, credentials: 'include' }).then(r => r.ok ? r.json() : { hyperlinks: {} }),
]);
const segments = [];
let sectionBreaks = [];
if (transcriptResp?.ok) {
const ct = transcriptResp.headers.get('content-type') ?? '';
if (ct.includes('application/json')) {
// JSON transcript — parse paragraphs/sentences and extract sectionBreaks
const tj = await transcriptResp.json();
sectionBreaks = tj.sectionBreaks ?? [];
for (const para of tj.paragraphs ?? []) {
if (para.off_the_record) {
const start = para.start ?? para.sentences?.[0]?.start ?? 0;
const end = para.end ?? para.sentences?.[para.sentences.length - 1]?.end ?? 0;
segments.push({ start, end, speaker: para.speaker, off_the_record: true });
} else if (para.sentences?.length) {
for (const sent of para.sentences) {
segments.push({ start: sent.start, end: sent.end, speaker: para.speaker, text: sent.text });
}
} else {
segments.push({ start: para.start, end: para.end, speaker: para.speaker, text: para.text ?? '' });
}
}
} else {
// CSV transcript
const text = await transcriptResp.text();
const lines = text.trim().split('\n');
for (let i = 1; i < lines.length; i++) {
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, sectionBreaks };
}
// ── 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();
if (searchBar.classList.contains('open')) { input.focus(); input.select(); } else { 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),
onUserSeek: idx => ctrl.onUserSeek(idx),
});
const HEADER_H = 56;
const playerGroup = document.getElementById('presPlayerGroup');
const transcriptEl = document.getElementById('presTranscript');
const presTranscript = new PresentationTranscript(transcriptEl, ctrl);
presTranscript.scrollToSegment = (idx) => {
if (idx < 0) return;
const el = transcriptEl.querySelector(`.pt-seg[data-idx="${idx}"]`);
if (!el) return;
el.style.scrollMarginTop = (HEADER_H + playerGroup.offsetHeight + 16) + 'px';
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
};
// ── Section nav ───────────────────────────────────────────────────────────
const presSectionNav = new PresentationSectionNav(
document.getElementById('presSectionNav'),
document.getElementById('presSectionNavList'),
document.getElementById('presSectionNavToggle'),
);
ctrl.setPanels(presWaveform, presTranscript, presSectionNav);
initSearch(presTranscript);
// ── Load project into panels ──────────────────────────────────────────────
showPresentation();
presWaveform.loadFromProject(project);
presTranscript.loadFromProject(project);
// Wire section nav after transcript is rendered (sections are populated by loadFromProject)
presSectionNav.setSections(presTranscript.sections);
presSectionNav.setScrollOffset(() => HEADER_H + playerGroup.offsetHeight + 16);
presSectionNav.setOnNavigate(t => ctrl.seekTo(t));
// ── Sticky shadow: add class when player group has left natural position ─────
const sentinel = document.getElementById('presWaveformSentinel');
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);
// ── Sticky top: keep section nav and search anchor just below the waveform ──
const searchAnchor = document.getElementById('presSearchAnchor');
const sectionNavEl = document.getElementById('presSectionNav');
/** Recalculates and applies top offsets for sticky section nav and search anchor. */
function _updateStickyTops() {
const playerBottom = HEADER_H + playerGroup.offsetHeight;
searchAnchor.style.top = (playerBottom + 8) + 'px';
sectionNavEl.style.top = (playerBottom + 20) + 'px';
}
_updateStickyTops();
new ResizeObserver(_updateStickyTops).observe(playerGroup);
}
init().catch(err => {
console.error('Presentation init failed:', err);
showState('presNoAccess');
});