/**
* EmbedDialog — modal for generating a "Live Quote" embed from a transcript selection.
*
* Two-column layout:
* Left: scrollable segment list with include/exclude toggles + options form
* Right: live preview that mirrors the final embed widget exactly, using the
* project's audio + peaks as stand-ins for the not-yet-generated clip.
*
* @example
* new EmbedDialog(selTarget, project, { getToken });
*/
export class EmbedDialog {
/**
* @param {object} selTarget - Selection target describing which segments to embed.
* @param {object} project - The active project.
* @param {object} [options] - Optional configuration.
* @param {function} [options.getToken] - Async function returning the current auth token, or null.
* @param {object} [options.wavesurfer] - WaveSurfer instance for preview playback.
*/
constructor(selTarget, project, { getToken, wavesurfer } = {}) {
this._getToken = getToken ?? (() => Promise.resolve(null));
this._project = project;
this._ws = wavesurfer ?? null;
// Normalise selTarget to a range
if ('segIdx' in selTarget) {
this._segIdxStart = selTarget.segIdx;
this._segIdxEnd = selTarget.segIdx;
} else {
this._segIdxStart = selTarget.segIdxStart;
this._segIdxEnd = selTarget.segIdxEnd;
}
this._charStart = selTarget.charStart;
this._charEnd = selTarget.charEnd;
this._segments = project.transcript().segments;
this._clipStart = this._segments[this._segIdxStart].start;
this._clipEnd = this._segments[this._segIdxEnd].end;
this._wsDuration = project.waveform().duration || 0;
// Segment include/hide map
this._includeMap = new Map();
for (let i = this._segIdxStart; i <= this._segIdxEnd; i++) {
this._includeMap.set(i, true);
}
// Trim checkboxes
const firstSeg = this._segments[this._segIdxStart];
const lastSeg = this._segments[this._segIdxEnd];
this._canTrimStart = this._charStart > 0;
this._canTrimEnd = this._segIdxStart === this._segIdxEnd
? this._charEnd < firstSeg.text.length
: this._charEnd < lastSeg.text.length;
this._trimStart = this._canTrimStart;
this._trimEnd = this._canTrimEnd;
// Speaker attribution default
const speakerId = firstSeg.speaker || '';
const speakers = typeof project.speakers === 'function' ? project.speakers() : (project.speakers || {});
this._speakerAttrDefault = speakers[speakerId]?.name || '';
// Default title: "<project> - <speaker> - P<para>, Ln<line>"
const transcriptObj = project.transcript();
let paraNum = 1, lineNum = 1;
for (let pi = 0; pi < transcriptObj.paragraphs.length; pi++) {
const si = transcriptObj.paragraphs[pi].segments.indexOf(firstSeg);
if (si !== -1) { paraNum = pi + 1; lineNum = si + 1; break; }
}
const speakerPart = this._speakerAttrDefault ? ` - ${this._speakerAttrDefault}` : '';
this._defaultTitle = `${project.projectName}${speakerPart} - \u00b6${paraNum}, \u2113${lineNum}`;
this._embedTitle = this._defaultTitle;
// Options
this._options = {
audio_format: 'mp3',
include_waveform: true,
font: 'Inter',
include_quotes: true,
include_title: false,
download_button: false,
speaker_attribution: null,
};
// Wire playback events on the shared WaveSurfer instance
this._wsHandlers = null;
this.#wireAudio();
// Live refs into the current preview DOM (swapped on each rebuild)
this._previewPlayBtn = null;
this._previewFill = null;
this._previewTimeEl = null;
this._previewCanvas = null;
this._previewSegEls = [];
this._busy = false;
this.#build();
}
// ── Audio setup ───────────────────────────────────────────────────────────
/**
* Registers handlers on the persistent audio element.
* Handler bodies reference `this._preview*` fields so they always
* target whichever DOM elements are currently live after a rebuild.
*/
#wireAudio() {
if (!this._ws) return;
const ws = this._ws;
const clipStart = this._clipStart;
const clipEnd = this._clipEnd;
const clipDur = clipEnd - clipStart;
const onPlay = () => {
if (this._previewPlayBtn) this._previewPlayBtn.innerHTML = '⏸';
};
const onPause = () => {
if (this._previewPlayBtn) this._previewPlayBtn.innerHTML = '▶';
};
const onTimeUpdate = (t) => {
if (t >= clipEnd) {
ws.pause();
if (this._wsDuration > 0) ws.seekTo(clipStart / this._wsDuration);
if (this._previewFill) this._previewFill.style.width = '0%';
if (this._previewTimeEl) this._previewTimeEl.textContent = '0:00';
this._previewSegEls.forEach(el => el.classList.remove('active'));
if (this._previewCanvas) this.#drawWaveform(this._previewCanvas, 0);
return;
}
const tRel = t - clipStart;
const progress = tRel / clipDur;
if (this._previewFill) this._previewFill.style.width = (progress * 100) + '%';
if (this._previewTimeEl) this._previewTimeEl.textContent = this.#fmtTime(tRel);
this._previewSegEls.forEach(el => {
const s = parseFloat(el.dataset.start);
const e = parseFloat(el.dataset.end);
el.classList.toggle('active', tRel >= s && tRel < e);
});
if (this._previewCanvas) this.#drawWaveform(this._previewCanvas, progress);
};
ws.on('play', onPlay);
ws.on('pause', onPause);
ws.on('audioprocess', onTimeUpdate);
this._wsHandlers = { onPlay, onPause, onTimeUpdate };
}
// ── Waveform ──────────────────────────────────────────────────────────────
/**
* Extracts the waveform peak samples for the current clip range from the project.
* @returns {number[]|null} Array of peak values, or null if unavailable.
*/
#getClipPeaks() {
const wf = typeof this._project.waveform === 'function'
? this._project.waveform()
: this._project.waveform;
const all = wf?.peaks?.[0]; // peaks is [[...channel0...], ...]
if (!all?.length) return null;
const pps = 140;
const clip = Array.from(all).slice(
Math.floor(this._clipStart * pps),
Math.ceil(this._clipEnd * pps),
);
return clip.length ? clip : null;
}
/**
* Draws the clip waveform onto the given canvas element.
* @param {HTMLCanvasElement} canvas - The canvas to draw on.
* @param {number} [progress=0] - Playback progress fraction (0–1) for the played-colour overlay.
*/
#drawWaveform(canvas, progress = 0) {
const peaks = this.#getClipPeaks();
const dpr = window.devicePixelRatio || 1;
const W = canvas.width = canvas.offsetWidth * dpr;
const H = canvas.height = canvas.offsetHeight * dpr;
if (!W || !H) return;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
if (!peaks) {
ctx.fillStyle = '#e5e7eb';
ctx.fillRect(0, H * 0.45, W, H * 0.1);
if (progress > 0) {
ctx.fillStyle = '#c8a84b';
ctx.fillRect(0, H * 0.45, W * progress, H * 0.1);
}
return;
}
const barW = W / peaks.length;
const playedX = progress * W;
for (let i = 0; i < peaks.length; i++) {
const x = i * barW;
const h = Math.max(2, peaks[i] * H * 0.85);
ctx.fillStyle = x < playedX ? '#c8a84b' : '#d1d5db';
ctx.fillRect(x, (H - h) / 2, Math.max(1, barW - 1), h);
}
}
// ── Build ─────────────────────────────────────────────────────────────────
/** Constructs and appends the dialog DOM to the document body. */
#build() {
this._scrim = document.createElement('div');
this._scrim.className = 'embed-dialog-scrim';
this._scrim.addEventListener('mousedown', (e) => {
if (e.target === this._scrim) this.close();
});
const modal = document.createElement('div');
modal.className = 'embed-dialog';
const header = document.createElement('div');
header.className = 'embed-dialog-header';
header.textContent = 'Generate Live Quote';
const body = document.createElement('div');
body.className = 'embed-dialog-body';
this._leftPanel = this.#buildLeftPanel();
this._rightPanel = this.#buildRightPanel();
body.appendChild(this._leftPanel);
body.appendChild(this._rightPanel);
const footer = document.createElement('div');
footer.className = 'embed-dialog-footer';
this._cancelBtn = document.createElement('button');
this._cancelBtn.className = 'btn btn-ghost';
this._cancelBtn.textContent = 'Cancel';
this._cancelBtn.addEventListener('click', () => this.close());
this._submitBtn = document.createElement('button');
this._submitBtn.className = 'btn btn-primary';
this._submitBtn.textContent = 'Create Embed Code';
this._submitBtn.addEventListener('click', () => this.#submit());
footer.appendChild(this._cancelBtn);
footer.appendChild(this._submitBtn);
modal.appendChild(header);
modal.appendChild(body);
modal.appendChild(footer);
this._scrim.appendChild(modal);
document.body.appendChild(this._scrim);
this.#updatePreview();
}
// ── Left panel ───────────────────────────────────────────────────────────
/**
* Builds the left panel containing the segment list, options, and duration bar.
* @returns {HTMLElement}
*/
#buildLeftPanel() {
const panel = document.createElement('div');
panel.className = 'embed-dialog-left';
panel.appendChild(this.#buildSegmentList());
panel.appendChild(this.#buildOptions());
panel.appendChild(this.#buildDurationBar());
return panel;
}
/**
* Builds the scrollable segment list with include/exclude checkboxes and trim rows.
* @returns {HTMLElement}
*/
#buildSegmentList() {
const list = document.createElement('div');
list.className = 'embed-seg-list';
const hint = document.createElement('p');
hint.className = 'embed-seg-hint';
hint.textContent = 'Uncheck any segments to replace them with an ellipsis in the embed. Audio plays through regardless.';
list.appendChild(hint);
for (let i = this._segIdxStart; i <= this._segIdxEnd; i++) {
const seg = this._segments[i];
if (i === this._segIdxStart && this._canTrimStart) {
list.appendChild(this.#buildTrimRow('start'));
}
const row = document.createElement('div');
row.className = 'embed-seg-row';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'embed-seg-toggle';
checkbox.checked = true;
checkbox.addEventListener('change', () => {
this._includeMap.set(i, checkbox.checked);
row.classList.toggle('embed-seg-row--excluded', !checkbox.checked);
this.#updatePreview();
});
const textEl = document.createElement('span');
textEl.className = 'embed-seg-text';
textEl.textContent = seg.text;
const timeEl = document.createElement('span');
timeEl.className = 'embed-seg-time';
timeEl.textContent = this.#fmtTime(seg.start);
row.appendChild(checkbox);
row.appendChild(textEl);
row.appendChild(timeEl);
list.appendChild(row);
if (i === this._segIdxEnd && this._canTrimEnd) {
list.appendChild(this.#buildTrimRow('end'));
}
}
return list;
}
/**
* Builds a trim checkbox row for the start or end of the selection.
* @param {'start'|'end'} which - Whether this row controls the start or end trim.
* @returns {HTMLElement}
*/
#buildTrimRow(which) {
const row = document.createElement('div');
row.className = 'embed-trim-row';
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = which === 'start' ? this._trimStart : this._trimEnd;
cb.addEventListener('change', () => {
if (which === 'start') this._trimStart = cb.checked;
else this._trimEnd = cb.checked;
this.#updatePreview();
});
label.appendChild(cb);
label.appendChild(document.createTextNode(
which === 'start'
? 'Trim start (show \u2026 before selected text)'
: 'Trim end (show selected text before \u2026)'
));
row.appendChild(label);
return row;
}
/**
* Builds the options form section (speaker attribution, checkboxes, selects).
* @returns {HTMLElement}
*/
#buildOptions() {
const wrap = document.createElement('div');
wrap.className = 'embed-options';
const title = document.createElement('div');
title.className = 'embed-options-title';
title.textContent = 'Options';
wrap.appendChild(title);
wrap.appendChild(this.#buildTitleRow());
wrap.appendChild(this.#buildSpeakerAttributionRow());
[
this.#optionCheckbox('Include title in embed', 'include_title',
(v) => { this._options.include_title = v; this.#updatePreview(); }),
this.#optionCheckbox('Include quote marks', 'include_quotes',
(v) => { this._options.include_quotes = v; this.#updatePreview(); }),
this.#optionCheckbox('Include waveform', 'include_waveform',
(v) => { this._options.include_waveform = v; this.#updatePreview(); }),
this.#optionCheckbox('Download audio button', 'download_button',
(v) => { this._options.download_button = v; this.#updatePreview(); }),
this.#optionSelect('Font', 'font',
['Inter', 'IBM Plex Sans', 'IBM Plex Mono', 'Georgia'],
(v) => { this._options.font = v; this.#updatePreview(); }),
this.#optionSelect('Audio format', 'audio_format',
['mp3', 'webm'],
(v) => { this._options.audio_format = v; }),
].forEach(r => wrap.appendChild(r));
return wrap;
}
/**
* Builds the quote name/title input row.
* @returns {HTMLElement}
*/
#buildTitleRow() {
const wrap = document.createElement('div');
wrap.className = 'embed-option-row embed-option-row--attribution';
const label = document.createElement('label');
label.className = 'embed-attr-label';
label.textContent = 'Quote name';
const input = document.createElement('input');
input.type = 'text';
input.className = 'embed-attr-input';
input.placeholder = 'Quote name';
input.value = this._defaultTitle;
input.addEventListener('input', () => {
this._embedTitle = input.value;
this.#updatePreview();
});
wrap.appendChild(label);
wrap.appendChild(input);
return wrap;
}
/**
* Builds the speaker attribution text input row.
* @returns {HTMLElement}
*/
#buildSpeakerAttributionRow() {
const wrap = document.createElement('div');
wrap.className = 'embed-option-row embed-option-row--attribution';
const label = document.createElement('label');
label.className = 'embed-attr-label';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = false;
label.appendChild(cb);
label.appendChild(document.createTextNode(' Speaker attribution'));
const input = document.createElement('input');
input.type = 'text';
input.className = 'embed-attr-input';
input.placeholder = 'Speaker name';
input.value = this._speakerAttrDefault;
input.disabled = true;
cb.addEventListener('change', () => {
input.disabled = !cb.checked;
this._options.speaker_attribution = cb.checked ? (input.value.trim() || null) : null;
this.#updatePreview();
});
input.addEventListener('input', () => {
if (cb.checked) {
this._options.speaker_attribution = input.value.trim() || null;
this.#updatePreview();
}
});
wrap.appendChild(label);
wrap.appendChild(input);
return wrap;
}
/**
* Builds a labelled checkbox option row.
* @param {string} label - Display label for the option.
* @param {string} key - Key in `this._options` to initialise from.
* @param {function} onChange - Called with the new boolean value when the checkbox changes.
* @returns {HTMLElement}
*/
#optionCheckbox(label, key, onChange) {
const row = document.createElement('div');
row.className = 'embed-option-row';
const lbl = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = this._options[key];
cb.addEventListener('change', () => onChange(cb.checked));
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(' ' + label));
row.appendChild(lbl);
return row;
}
/**
* Builds a labelled select option row.
* @param {string} label - Display label for the option.
* @param {string} key - Key in `this._options` to initialise from.
* @param {string[]} choices - The available option values.
* @param {function} onChange - Called with the selected string value when the select changes.
* @returns {HTMLElement}
*/
#optionSelect(label, key, choices, onChange) {
const row = document.createElement('div');
row.className = 'embed-option-row';
const lbl = document.createElement('span');
lbl.textContent = label;
const sel = document.createElement('select');
choices.forEach(c => {
const opt = document.createElement('option');
opt.value = c; opt.textContent = c;
if (c === this._options[key]) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener('change', () => onChange(sel.value));
row.appendChild(lbl);
row.appendChild(sel);
return row;
}
/**
* Builds the clip duration indicator element.
* @returns {HTMLElement}
*/
#buildDurationBar() {
this._durationEl = document.createElement('div');
this._durationEl.className = 'embed-duration';
this.#refreshDuration();
return this._durationEl;
}
/** Updates the duration bar text and submit button state based on clip length. */
#refreshDuration() {
if (!this._durationEl) return;
const dur = this._clipEnd - this._clipStart;
const secs = dur.toFixed(1);
const remain = (60 - dur).toFixed(1);
this._durationEl.className = 'embed-duration';
if (dur > 60) {
this._durationEl.className += ' embed-duration--error';
this._durationEl.textContent = `\u26a0 Clip is ${secs}s — exceeds the 60-second limit`;
if (this._submitBtn) this._submitBtn.disabled = true;
} else if (dur > 50) {
this._durationEl.className += ' embed-duration--warn';
this._durationEl.textContent = `Clip: ${secs}s (${remain}s remaining)`;
if (this._submitBtn) this._submitBtn.disabled = false;
} else {
this._durationEl.textContent = `Clip: ${secs}s`;
if (this._submitBtn) this._submitBtn.disabled = false;
}
}
// ── Right panel ──────────────────────────────────────────────────────────
/**
* Builds the right preview panel containing the live embed preview.
* @returns {HTMLElement}
*/
#buildRightPanel() {
const panel = document.createElement('div');
panel.className = 'embed-dialog-right';
const label = document.createElement('div');
label.className = 'embed-preview-label';
label.textContent = 'Preview';
this._previewEl = document.createElement('div');
this._previewEl.className = 'embed-preview';
panel.appendChild(label);
panel.appendChild(this._previewEl);
return panel;
}
// ── Live preview ─────────────────────────────────────────────────────────
/**
* Rebuilds the full .lq-widget preview using the same DOM structure as the
* generated embed, but wired to the project audio (seeking to the clip range)
* and project peaks (sliced to the clip range) instead of server-generated data.
*/
#updatePreview() {
if (!this._previewEl) return;
// Keep audio playing across rebuilds — just swap the DOM refs below
const wasPlaying = this._ws?.isPlaying() ?? false;
const config = this.#computeSegmentsConfig();
const options = this._options;
this._previewEl.innerHTML = '';
const widget = document.createElement('div');
widget.className = 'lq-widget';
widget.style.fontFamily = `'${options.font}', system-ui, sans-serif`;
// ── Transcript ──────────────────────────────────────────────────────
const transcript = document.createElement('div');
transcript.className = 'lq-transcript';
if (options.include_quotes) {
const oq = document.createElement('span');
oq.className = 'lq-quote lq-quote-open';
oq.textContent = '\u201c';
transcript.appendChild(oq);
}
const segEls = [];
let ellipsisPending = false;
config.forEach(sc => {
if (!sc.included) {
ellipsisPending = true;
} else {
if (ellipsisPending) {
const ell = document.createElement('span');
ell.className = 'lq-ellipsis';
ell.textContent = '\u2026';
transcript.appendChild(ell);
ellipsisPending = false;
}
const seg = this._segments[sc.segment_idx];
const span = document.createElement('span');
span.className = 'lq-seg';
span.dataset.start = (seg.start - this._clipStart).toFixed(3);
span.dataset.end = (seg.end - this._clipStart).toFixed(3);
span.textContent = sc.display_text || seg.text;
// Clicking a segment seeks to it
span.addEventListener('click', () => {
if (this._ws && this._wsDuration > 0) {
this._ws.seekTo(seg.start / this._wsDuration);
if (!this._ws.isPlaying()) this._ws.play();
}
});
segEls.push(span);
transcript.appendChild(span);
}
});
if (ellipsisPending) {
const ell = document.createElement('span');
ell.className = 'lq-ellipsis';
ell.textContent = '\u2026';
transcript.appendChild(ell);
}
if (options.include_quotes) {
const cq = document.createElement('span');
cq.className = 'lq-quote lq-quote-close';
cq.textContent = '\u201d';
transcript.appendChild(cq);
}
// ── Title ────────────────────────────────────────────────────────
if (options.include_title && this._embedTitle) {
const titleEl = document.createElement('div');
titleEl.className = 'lq-title';
titleEl.textContent = this._embedTitle;
widget.appendChild(titleEl);
}
widget.appendChild(transcript);
// ── Attribution ──────────────────────────────────────────────────
if (options.speaker_attribution) {
const attr = document.createElement('div');
attr.className = 'lq-attribution';
attr.textContent = '\u2014 ' + options.speaker_attribution;
widget.appendChild(attr);
}
// ── Waveform canvas ──────────────────────────────────────────────
const canvas = document.createElement('canvas');
canvas.className = 'lq-waveform';
canvas.style.display = options.include_waveform ? 'block' : 'none';
canvas.style.cursor = 'pointer';
canvas.addEventListener('click', (e) => {
if (!this._ws || !this._wsDuration) return;
const r = canvas.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
this._ws.seekTo((this._clipStart + frac * (this._clipEnd - this._clipStart)) / this._wsDuration);
});
widget.appendChild(canvas);
// ── Controls ─────────────────────────────────────────────────────
const controls = document.createElement('div');
controls.className = 'lq-controls';
const playBtn = document.createElement('button');
playBtn.className = 'lq-play';
playBtn.innerHTML = wasPlaying ? '⏸' : '▶';
playBtn.addEventListener('click', () => this.#playPause());
const progressWrap = document.createElement('div');
progressWrap.className = 'lq-progress-wrap';
const fill = document.createElement('div');
fill.className = 'lq-progress-fill';
// Restore progress position if ws is mid-clip
const wsT = this._ws?.getCurrentTime() ?? 0;
if (wsT >= this._clipStart && wsT < this._clipEnd) {
const p = (wsT - this._clipStart) / (this._clipEnd - this._clipStart);
fill.style.width = (p * 100) + '%';
}
progressWrap.appendChild(fill);
progressWrap.addEventListener('click', (e) => {
if (!this._ws || !this._wsDuration) return;
const r = progressWrap.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
this._ws.seekTo((this._clipStart + frac * (this._clipEnd - this._clipStart)) / this._wsDuration);
});
const timeEl = document.createElement('span');
timeEl.className = 'lq-time';
const tRel = Math.max(0, wsT - this._clipStart);
timeEl.textContent = this.#fmtTime(
(wsT >= this._clipStart && wsT < this._clipEnd) ? tRel : 0
);
if (options.download_button) {
const dl = document.createElement('span');
dl.className = 'lq-download';
dl.textContent = '\u2193';
dl.title = 'Available after generating embed';
controls.appendChild(playBtn);
controls.appendChild(progressWrap);
controls.appendChild(timeEl);
controls.appendChild(dl);
} else {
controls.appendChild(playBtn);
controls.appendChild(progressWrap);
controls.appendChild(timeEl);
}
widget.appendChild(controls);
// ── Footer ────────────────────────────────────────────────────────
const footer = document.createElement('div');
footer.className = 'lq-footer';
const link = document.createElement('a');
link.href = 'https://waveformstudio.app';
link.target = '_blank';
link.textContent = 'Waveform Studio - Live Quote';
footer.appendChild(link);
widget.appendChild(footer);
this._previewEl.appendChild(widget);
// Swap live DOM refs so the persistent audio handlers target new elements
this._previewPlayBtn = playBtn;
this._previewFill = fill;
this._previewTimeEl = timeEl;
this._previewCanvas = canvas;
this._previewSegEls = segEls;
// Draw waveform after layout so canvas.offsetWidth is non-zero
if (options.include_waveform) {
requestAnimationFrame(() => requestAnimationFrame(() => {
const ct = this._ws?.getCurrentTime() ?? 0;
const p = (ct >= this._clipStart && ct < this._clipEnd)
? (ct - this._clipStart) / (this._clipEnd - this._clipStart)
: 0;
this.#drawWaveform(canvas, p);
}));
}
}
// ── Segments config ───────────────────────────────────────────────────────
/**
* Builds the segments_config array for the API request, applying include/exclude state and trim overrides.
* @returns {object[]} Array of segment config objects for the embed API.
*/
#computeSegmentsConfig() {
const config = [];
const single = this._segIdxStart === this._segIdxEnd;
for (let i = this._segIdxStart; i <= this._segIdxEnd; i++) {
const seg = this._segments[i];
const included = this._includeMap.get(i) !== false;
let displayText = null;
const isFirst = i === this._segIdxStart;
const isLast = i === this._segIdxEnd;
if (single) {
const applyStart = this._trimStart && this._charStart > 0;
const applyEnd = this._trimEnd && this._charEnd < seg.text.length;
if (applyStart || applyEnd) {
const from = applyStart ? this._charStart : 0;
const to = applyEnd ? this._charEnd : seg.text.length;
displayText = (applyStart ? '\u2026' : '') + seg.text.slice(from, to) + (applyEnd ? '\u2026' : '');
}
} else {
if (isFirst && this._trimStart && this._charStart > 0) {
displayText = '\u2026' + seg.text.slice(this._charStart);
} else if (isLast && this._trimEnd && this._charEnd < seg.text.length) {
displayText = seg.text.slice(0, this._charEnd) + '\u2026';
}
}
config.push({ segment_idx: i, included, display_text: displayText });
}
return config;
}
// ── Submission ────────────────────────────────────────────────────────────
/** Submits the embed creation request to the server and shows the resulting snippet. */
async #submit() {
if (this._busy) return;
this._busy = true;
this._submitBtn.disabled = true;
this._submitBtn.innerHTML = '<span class="embed-spinner"></span> Generating\u2026';
this.#clearError();
if (this._ws?.isPlaying()) this._ws.pause();
const body = {
project_id: this._project.projectId,
segments_config: this.#computeSegmentsConfig(),
name: this._embedTitle,
options: { ...this._options, title: this._embedTitle },
};
try {
const token = await this._getToken();
const headers = { 'Content-Type': 'application/json' };
if (token) headers['X-Auth-Token'] = token;
const resp = await fetch('/api/embeds', { method: 'POST', headers, body: JSON.stringify(body) });
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `Server error ${resp.status}`);
this.#showSnippet(data.url);
} catch (err) {
this.#showError(err.message || 'Failed to create embed');
this._submitBtn.disabled = false;
this._submitBtn.textContent = 'Create Embed Code';
this._busy = false;
}
}
/**
* Replaces the right panel with the generated embed code snippet and copy button.
* @param {string} embedUrl - The server-relative URL of the generated embed.
*/
#showSnippet(embedUrl) {
const fullUrl = `${window.location.origin}${embedUrl}`;
const snippet = `<iframe src="${fullUrl}" width="660" height="280" frameborder="0" scrolling="no" style="border:none;max-width:100%;border-radius:12px;"></iframe>`;
this._rightPanel.innerHTML = '';
const wrap = document.createElement('div');
wrap.className = 'embed-snippet-wrap';
const label = document.createElement('div');
label.className = 'embed-snippet-label';
label.textContent = 'Embed Code';
const textarea = document.createElement('textarea');
textarea.className = 'embed-snippet-code';
textarea.readOnly = true;
textarea.rows = 4;
textarea.value = snippet;
const copyBtn = document.createElement('button');
copyBtn.className = 'btn btn-secondary embed-copy-btn';
copyBtn.textContent = 'Copy to clipboard';
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(snippet).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy to clipboard'; }, 2000);
});
});
const linkLabel = document.createElement('div');
linkLabel.className = 'embed-snippet-label';
linkLabel.textContent = 'Embed Link';
const linkInput = document.createElement('input');
linkInput.type = 'text';
linkInput.className = 'embed-snippet-link';
linkInput.readOnly = true;
linkInput.value = fullUrl;
const copyLinkBtn = document.createElement('button');
copyLinkBtn.className = 'btn btn-secondary embed-copy-btn';
copyLinkBtn.textContent = 'Copy link';
copyLinkBtn.addEventListener('click', () => {
navigator.clipboard.writeText(fullUrl).then(() => {
copyLinkBtn.textContent = 'Copied!';
setTimeout(() => { copyLinkBtn.textContent = 'Copy link'; }, 2000);
});
});
wrap.appendChild(label);
wrap.appendChild(textarea);
wrap.appendChild(copyBtn);
wrap.appendChild(linkLabel);
wrap.appendChild(linkInput);
wrap.appendChild(copyLinkBtn);
this._rightPanel.appendChild(wrap);
this._submitBtn.style.display = 'none';
this._cancelBtn.textContent = 'Done';
requestAnimationFrame(() => textarea.select());
}
/**
* Displays an error message in the right panel.
* @param {string} msg - The error message to display.
*/
#showError(msg) {
this.#clearError();
const err = document.createElement('div');
err.className = 'embed-error';
err.textContent = '\u26a0 ' + msg;
this._rightPanel.appendChild(err);
}
/** Removes any existing error elements from the right panel. */
#clearError() {
this._rightPanel.querySelectorAll('.embed-error').forEach(el => el.remove());
}
// ── Helpers ───────────────────────────────────────────────────────────────
/** Toggles WaveSurfer playback, seeking to the clip start if the playhead is outside the clip. */
#playPause() {
const ws = this._ws;
if (!ws) return;
if (ws.isPlaying()) { ws.pause(); return; }
const t = ws.getCurrentTime();
if (t < this._clipStart || t >= this._clipEnd) {
if (this._wsDuration > 0) ws.seekTo(this._clipStart / this._wsDuration);
}
ws.play();
}
/**
* Formats a time in seconds as M:SS.
* @param {number} secs - Time in seconds.
* @returns {string}
*/
#fmtTime(secs) {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return m + ':' + String(s).padStart(2, '0');
}
/** Detaches WaveSurfer event listeners and removes the dialog from the DOM. */
close() {
if (this._ws && this._wsHandlers) {
this._ws.un('play', this._wsHandlers.onPlay);
this._ws.un('pause', this._wsHandlers.onPause);
this._ws.un('audioprocess', this._wsHandlers.onTimeUpdate);
this._wsHandlers = null;
}
this._scrim.remove();
}
}