368 lines
13 KiB
JavaScript
368 lines
13 KiB
JavaScript
/** DOM rendering. All element lookups and mutations live here. */
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
|
||
export const el = {
|
||
modeBadge: $('modeBadge'),
|
||
connPill: $('connPill'),
|
||
connLabel: $('connLabel'),
|
||
latencyChip: $('latencyChip'),
|
||
latencyValue: $('latencyValue'),
|
||
reconnectBtn: $('reconnectBtn'),
|
||
|
||
banner: $('configBanner'),
|
||
bannerTitle: $('bannerTitle'),
|
||
bannerText: $('bannerText'),
|
||
|
||
textInput: $('textInput'),
|
||
counter: $('counter'),
|
||
counterNow: $('counterNow'),
|
||
counterMax: $('counterMax'),
|
||
|
||
speakBtn: $('speakBtn'),
|
||
speakLabel: $('speakLabel'),
|
||
stopBtn: $('stopBtn'),
|
||
clearBtn: $('clearBtn'),
|
||
|
||
pipeline: $('pipeline'),
|
||
pipelineFill: $('pipelineFill'),
|
||
|
||
live: $('live'),
|
||
liveText: $('liveText'),
|
||
liveDetail: $('liveDetail'),
|
||
|
||
factState: $('factState'),
|
||
factMode: $('factMode'),
|
||
factTransport: $('factTransport'),
|
||
factAddress: $('factAddress'),
|
||
factLatency: $('factLatency'),
|
||
factUptime: $('factUptime'),
|
||
statusNote: $('statusNote'),
|
||
|
||
audioList: $('audioList'),
|
||
audioCount: $('audioCount'),
|
||
audioNote: $('audioNote'),
|
||
clearAudioBtn: $('clearAudioBtn'),
|
||
|
||
historyList: $('historyList'),
|
||
historyEmpty: $('historyEmpty'),
|
||
historyCount: $('historyCount'),
|
||
clearHistoryBtn: $('clearHistoryBtn'),
|
||
|
||
toasts: $('toasts'),
|
||
};
|
||
|
||
const CONNECTION_LABEL = {
|
||
connected: 'Robot Connected',
|
||
connecting: 'Connecting…',
|
||
disconnected: 'Robot Disconnected',
|
||
error: 'Connection Error',
|
||
};
|
||
|
||
const PIPELINE_PROGRESS = {
|
||
idle: 0, queued: 8, sending: 26, processing: 52, speaking: 80,
|
||
completed: 100, failed: 100, cancelled: 100,
|
||
};
|
||
|
||
const STEP_ORDER = ['sending', 'processing', 'speaking', 'completed'];
|
||
|
||
// -------------------------------------------------------------------------
|
||
// header / connection
|
||
// -------------------------------------------------------------------------
|
||
export function renderConnection(status) {
|
||
const state = status.state || 'disconnected';
|
||
el.connPill.dataset.state = state;
|
||
el.connLabel.textContent = CONNECTION_LABEL[state] || state;
|
||
|
||
const latency = status.latencyMs;
|
||
if (typeof latency === 'number' && state === 'connected') {
|
||
el.latencyChip.hidden = false;
|
||
el.latencyValue.textContent = `${latency < 10 ? latency.toFixed(1) : Math.round(latency)} ms`;
|
||
} else {
|
||
el.latencyChip.hidden = true;
|
||
}
|
||
}
|
||
|
||
export function renderConfig(config) {
|
||
if (!config) return;
|
||
const mode = (config.mode || 'mock').toLowerCase();
|
||
el.modeBadge.textContent = mode === 'real' ? 'Live Robot' : 'Mock Mode';
|
||
el.modeBadge.dataset.mode = mode;
|
||
|
||
const max = config.maxLength || 1000;
|
||
el.counterMax.textContent = String(max);
|
||
el.textInput.maxLength = max;
|
||
|
||
const issues = config.issues || [];
|
||
if (issues.length) {
|
||
const worst = issues.find((i) => i.level === 'error') || issues[0];
|
||
el.banner.hidden = false;
|
||
el.banner.dataset.level = worst.level;
|
||
el.bannerTitle.textContent =
|
||
worst.level === 'error' ? 'Configuration error' : 'Configuration warning';
|
||
el.bannerText.textContent = `${worst.message} (${worst.key} in ${config.envFile || '.env'})`;
|
||
} else {
|
||
el.banner.hidden = true;
|
||
}
|
||
}
|
||
|
||
export function renderFacts(status) {
|
||
const robot = status.robot || {};
|
||
const state = status.state || 'disconnected';
|
||
|
||
el.factState.textContent = CONNECTION_LABEL[state] || state;
|
||
el.factState.dataset.tone =
|
||
state === 'connected' ? 'ok' : state === 'connecting' ? 'warn' : 'error';
|
||
|
||
el.factMode.textContent = robot.mode === 'real' ? 'Live robot' : 'Simulation';
|
||
el.factTransport.textContent = (robot.transport || '–').toUpperCase();
|
||
el.factAddress.textContent = robot.address || '–';
|
||
el.factAddress.title = robot.address || '';
|
||
|
||
el.factLatency.textContent =
|
||
typeof status.latencyMs === 'number' ? `${Math.round(status.latencyMs)} ms` : '–';
|
||
el.factUptime.textContent =
|
||
typeof status.uptimeSeconds === 'number' ? formatDuration(status.uptimeSeconds) : '–';
|
||
|
||
if (status.error) {
|
||
el.statusNote.hidden = false;
|
||
el.statusNote.textContent = status.error;
|
||
} else {
|
||
el.statusNote.hidden = true;
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// composer
|
||
// -------------------------------------------------------------------------
|
||
export function renderCounter(length, max) {
|
||
el.counterNow.textContent = String(length);
|
||
const ratio = max ? length / max : 0;
|
||
el.counter.dataset.level = ratio >= 1 ? 'over' : ratio > 0.85 ? 'warn' : 'ok';
|
||
}
|
||
|
||
export function setControls({ canSpeak, busy, canStop }) {
|
||
el.speakBtn.disabled = !canSpeak;
|
||
el.speakBtn.dataset.busy = busy ? 'true' : 'false';
|
||
el.speakLabel.textContent = busy ? 'Speaking…' : 'Speak';
|
||
el.stopBtn.disabled = !canStop;
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// pipeline + live status
|
||
// -------------------------------------------------------------------------
|
||
export function renderPipeline(stage) {
|
||
const key = PIPELINE_PROGRESS[stage] === undefined ? 'idle' : stage;
|
||
el.pipeline.dataset.stage = key;
|
||
el.pipelineFill.style.inset = `0 ${100 - PIPELINE_PROGRESS[key]}% 0 0`;
|
||
|
||
const activeIndex = STEP_ORDER.indexOf(
|
||
key === 'queued' ? 'sending' : key === 'failed' || key === 'cancelled' ? 'speaking' : key,
|
||
);
|
||
el.pipeline.querySelectorAll('li').forEach((li, index) => {
|
||
if (key === 'idle') { li.removeAttribute('data-on'); return; }
|
||
if (index < activeIndex) li.dataset.on = 'done';
|
||
else if (index === activeIndex) li.dataset.on = 'active';
|
||
else li.removeAttribute('data-on');
|
||
});
|
||
}
|
||
|
||
export function renderLive(tone, text, detail = '') {
|
||
el.live.dataset.tone = tone;
|
||
el.liveText.textContent = text;
|
||
el.liveDetail.textContent = detail || '';
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// history
|
||
// -------------------------------------------------------------------------
|
||
export function renderHistory(items, onPick, onReplay = () => {}) {
|
||
el.historyCount.textContent = String(items.length);
|
||
el.historyEmpty.hidden = items.length > 0;
|
||
el.historyList.innerHTML = '';
|
||
|
||
for (const item of items) {
|
||
const li = document.createElement('li');
|
||
li.className = 'hitem';
|
||
li.dataset.ok = item.success === null || item.success === undefined
|
||
? (isTerminal(item.stage) ? 'false' : 'pending')
|
||
: String(Boolean(item.success));
|
||
li.title = 'Click to put this text back in the box';
|
||
li.tabIndex = 0;
|
||
|
||
const bar = document.createElement('span');
|
||
bar.className = 'hitem__bar';
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'hitem__body';
|
||
|
||
const text = document.createElement('div');
|
||
text.className = 'hitem__text';
|
||
text.textContent = item.text;
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'hitem__meta';
|
||
meta.append(spanOf(formatTime(item.at)));
|
||
if (typeof item.ackLatencyMs === 'number') meta.append(spanOf(`${item.ackLatencyMs} ms`));
|
||
if (item.error) {
|
||
const err = spanOf(item.error);
|
||
err.className = 'err';
|
||
meta.append(err);
|
||
} else if (!isTerminal(item.stage)) {
|
||
meta.append(spanOf(item.stage));
|
||
}
|
||
|
||
body.append(text, meta);
|
||
li.append(bar, body);
|
||
|
||
if (item.audioSaved) {
|
||
const replay = document.createElement('button');
|
||
replay.className = 'hitem__replay';
|
||
replay.title = 'Play the saved audio instantly';
|
||
replay.setAttribute('aria-label', 'Replay this line');
|
||
replay.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
|
||
replay.addEventListener('click', (event) => {
|
||
event.stopPropagation(); // do not also load it into the box
|
||
onReplay(item);
|
||
});
|
||
li.append(replay);
|
||
}
|
||
|
||
const pick = () => onPick(item.text);
|
||
li.addEventListener('click', pick);
|
||
li.addEventListener('keydown', (event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); pick(); }
|
||
});
|
||
|
||
el.historyList.append(li);
|
||
}
|
||
}
|
||
|
||
function isTerminal(stage) {
|
||
return ['completed', 'failed', 'cancelled'].includes(stage);
|
||
}
|
||
|
||
function spanOf(text) {
|
||
const span = document.createElement('span');
|
||
span.textContent = text;
|
||
return span;
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// toasts
|
||
// -------------------------------------------------------------------------
|
||
export function toast(message, tone = 'info', ttl = 5000) {
|
||
const node = document.createElement('div');
|
||
node.className = 'toast';
|
||
node.dataset.tone = tone;
|
||
|
||
const dot = document.createElement('span');
|
||
dot.className = 'toast__dot';
|
||
const text = document.createElement('div');
|
||
text.textContent = message;
|
||
|
||
node.append(dot, text);
|
||
el.toasts.append(node);
|
||
|
||
const remove = () => {
|
||
node.classList.add('is-out');
|
||
setTimeout(() => node.remove(), 220);
|
||
};
|
||
const timer = setTimeout(remove, ttl);
|
||
node.addEventListener('click', () => { clearTimeout(timer); remove(); });
|
||
return node;
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// helpers
|
||
// -------------------------------------------------------------------------
|
||
export function formatTime(epochSeconds) {
|
||
if (!epochSeconds) return '';
|
||
return new Date(epochSeconds * 1000).toLocaleTimeString([], {
|
||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||
});
|
||
}
|
||
|
||
export function formatDuration(seconds) {
|
||
const total = Math.max(0, Math.round(seconds));
|
||
if (total < 60) return `${total}s`;
|
||
const minutes = Math.floor(total / 60);
|
||
if (minutes < 60) return `${minutes}m ${total % 60}s`;
|
||
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||
}
|
||
|
||
|
||
// -------------------------------------------------------------------------
|
||
// saved audio
|
||
// -------------------------------------------------------------------------
|
||
const PLAY_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
|
||
const STOP_ICON = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>';
|
||
const DOWNLOAD_ICON =
|
||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||
'stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/>' +
|
||
'<path d="m7 12 5 5 5-5"/><path d="M5 21h14"/></svg>';
|
||
|
||
export function renderAudio(items, stats, { fileUrl, onPlayState }) {
|
||
el.audioCount.textContent = String(items.length);
|
||
el.audioList.innerHTML = '';
|
||
|
||
if (!items.length) {
|
||
el.audioNote.textContent =
|
||
'Nothing saved yet. Speak a line with the neural voice and its audio is ' +
|
||
'kept here as a .wav, so it replays instantly with no synthesis.';
|
||
return;
|
||
}
|
||
// Keep the note to one short line; the full path would overflow the panel,
|
||
// so it goes in the tooltip instead.
|
||
el.audioNote.textContent =
|
||
`${items.length} clip(s) · ${(stats.seconds || 0).toFixed(0)}s · ` +
|
||
`${((stats.bytes || 0) / 1048576).toFixed(1)} MB · audio_library/`;
|
||
el.audioNote.title = stats.dir || '';
|
||
|
||
for (const item of items) {
|
||
const li = document.createElement('li');
|
||
li.className = 'aitem';
|
||
|
||
const button = document.createElement('button');
|
||
button.className = 'aitem__play';
|
||
button.innerHTML = PLAY_ICON;
|
||
button.title = 'Play in this browser';
|
||
|
||
// Played in the page, not through the backend: this is you auditioning a
|
||
// saved clip, not the robot speaking. Keeping them separate means the
|
||
// dashboard's Speaking state never lies.
|
||
const audio = new Audio(fileUrl(item.id));
|
||
button.addEventListener('click', () => {
|
||
if (!audio.paused) { audio.pause(); audio.currentTime = 0; return; }
|
||
onPlayState(audio);
|
||
audio.play().catch(() => {});
|
||
});
|
||
audio.addEventListener('play', () => { button.dataset.playing = 'true'; button.innerHTML = STOP_ICON; });
|
||
const reset = () => { button.dataset.playing = 'false'; button.innerHTML = PLAY_ICON; };
|
||
audio.addEventListener('pause', reset);
|
||
audio.addEventListener('ended', reset);
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'aitem__body';
|
||
const text = document.createElement('div');
|
||
text.className = 'aitem__text';
|
||
text.textContent = item.text;
|
||
text.title = item.text;
|
||
const meta = document.createElement('div');
|
||
meta.className = 'aitem__meta';
|
||
meta.textContent = `${(item.durationSeconds || 0).toFixed(1)}s · ${item.voice} · ${item.file}`;
|
||
meta.title = item.file;
|
||
body.append(text, meta);
|
||
|
||
const download = document.createElement('a');
|
||
download.className = 'aitem__dl';
|
||
download.href = fileUrl(item.id);
|
||
download.download = item.file;
|
||
download.title = 'Download this .wav';
|
||
download.innerHTML = DOWNLOAD_ICON;
|
||
|
||
li.append(button, body, download);
|
||
el.audioList.append(li);
|
||
}
|
||
}
|