SG Announcer: Piper-TTS-utroper med forhåndslytt og kø

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-06 03:18:13 +02:00
commit 1c3e17afa0
8 changed files with 971 additions and 0 deletions

230
index.html Normal file
View file

@ -0,0 +1,230 @@
<!doctype html>
<html lang="no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SG Announcer</title>
<style>
:root {
--bg: #0e1116; --panel: #171c24; --panel2: #1f2631; --line: #2a3340;
--fg: #e6ebf2; --muted: #8a96a8; --accent: #4fd1c5; --accent-fg: #06201d;
--danger: #f06565; --ok: #7bd88f; --warn: #f5c451;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 720px; margin: 0 auto; padding: 24px 16px 48px; }
h1 { font-size: 22px; margin: 0 0 4px; letter-spacing: .02em; }
h1 span { color: var(--accent); }
.sub { color: var(--muted); margin: 0 0 20px; font-size: 14px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin-bottom: 16px; }
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: .06em; }
textarea { width: 100%; min-height: 120px; resize: vertical; background: var(--panel2); color: var(--fg);
border: 1px solid var(--line); border-radius: 8px; padding: 12px; font: inherit; font-size: 18px; }
textarea:focus, select:focus { outline: 2px solid var(--accent); outline-offset: 0; border-color: transparent; }
.counter { text-align: right; font-size: 12px; color: var(--muted); margin-top: 4px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; margin-top: 12px; }
.row > div { flex: 1 1 160px; }
select, input[type=range] { width: 100%; }
select { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; }
.check { display: flex; align-items: center; gap: 8px; padding: 10px 0; font-size: 15px; }
.check input { width: 18px; height: 18px; accent-color: var(--accent); }
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.chip { background: var(--panel2); border: 1px solid var(--line); color: var(--fg); border-radius: 999px;
padding: 6px 12px; font-size: 13px; cursor: pointer; }
.chip:hover { border-color: var(--accent); }
.actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px; }
button.btn { flex: 1 1 200px; padding: 14px 18px; font: inherit; font-weight: 600; font-size: 16px;
border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--fg); cursor: pointer; }
button.btn:disabled { opacity: .5; cursor: default; }
button.primary { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
button.danger { background: transparent; color: var(--danger); border-color: var(--danger); flex: 0 1 auto; }
audio { width: 100%; margin-top: 12px; display: none; }
audio.show { display: block; }
.msg { margin-top: 10px; font-size: 14px; min-height: 1.4em; }
.msg.err { color: var(--danger); } .msg.ok { color: var(--ok); }
.status-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.status-head h2 { font-size: 14px; margin: 0; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
.now { padding: 12px; border-radius: 8px; background: var(--panel2); border-left: 4px solid var(--ok); }
.now.idle { border-left-color: var(--line); color: var(--muted); }
.now.idle::before { content: "Stille · ingenting i kø"; }
ul.list { list-style: none; padding: 0; margin: 8px 0 0; }
ul.list li { padding: 8px 0; border-top: 1px solid var(--line); font-size: 14px; display: flex; gap: 10px; }
ul.list li .t { color: var(--muted); min-width: 48px; font-variant-numeric: tabular-nums; }
ul.list li .s { margin-left: auto; color: var(--muted); font-size: 12px; white-space: nowrap; }
ul.list li.failed .s { color: var(--danger); } ul.list li.stopped .s { color: var(--warn); }
.pulse { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); margin-right: 8px;
animation: pulse 1.2s infinite; vertical-align: middle; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .25 } }
#pinbox { display: none; }
#pinbox input { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; width: 100%; }
</style>
</head>
<body>
<main>
<h1>SG <span>Announcer</span></h1>
<p class="sub">Skriv meldingen, forhåndslytt, og send den ut på anlegget.</p>
<div class="card" id="pinbox">
<label for="pin">PIN</label>
<input id="pin" type="password" inputmode="numeric" placeholder="Spør i crew-rommet">
</div>
<div class="card">
<label for="text">Melding</label>
<textarea id="text" placeholder="F.eks. «Pizzaen er kommet. Hent i kantina!»" autofocus></textarea>
<div class="counter"><span id="count">0</span> / <span id="max">600</span></div>
<div class="chips" id="chips"></div>
<div class="row">
<div>
<label for="voice">Stemme</label>
<select id="voice"></select>
</div>
<div>
<label for="speed">Hastighet <span id="speedval">1.0×</span></label>
<input id="speed" type="range" min="0.7" max="1.4" step="0.05" value="1.0">
</div>
<div style="flex:0 1 auto">
<div class="check"><input id="chime" type="checkbox" checked><label for="chime" style="margin:0;text-transform:none;letter-spacing:0;color:var(--fg)">Ding-dong først</label></div>
</div>
</div>
<div class="actions">
<button class="btn" id="btnPreview">▶ Forhåndslytt</button>
<button class="btn primary" id="btnAnnounce" disabled>📢 Send ut på anlegget</button>
</div>
<audio id="player" controls></audio>
<div class="msg" id="msg"></div>
</div>
<div class="card">
<div class="status-head">
<h2>Anlegget</h2>
<div>
<button class="btn danger" id="btnStop" style="padding:6px 12px;font-size:13px">■ Stopp</button>
<button class="btn danger" id="btnClear" style="padding:6px 12px;font-size:13px">Tøm kø</button>
</div>
</div>
<div class="now idle" id="now"></div>
<ul class="list" id="queue"></ul>
</div>
<div class="card">
<div class="status-head"><h2>Siste meldinger</h2></div>
<ul class="list" id="history"></ul>
</div>
</main>
<script>
const $ = s => document.querySelector(s);
const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime');
const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce');
let cfg = {}, previewedKey = null;
const QUICK = [
'Hei alle sammen! Pizzaen er kommet, hent i kantina.',
'Turneringen starter om ti minutter. Møt opp ved scenen.',
'Husk å rydde rundt plassen din. Takk!',
'Natta! Vi senker lyset, hold volumet nede.',
];
function key() { return JSON.stringify(payload()); }
function payload() {
return { text: text.value.trim(), voice: voice.value, speed: parseFloat(speed.value), chime: chime.checked };
}
function headers() {
const h = { 'Content-Type': 'application/json' };
const pin = localStorage.getItem('pin');
if (pin) h['X-Pin'] = pin;
return h;
}
function say(t, cls) { msg.textContent = t; msg.className = 'msg ' + (cls || ''); }
function updateAnnounce() {
btnAnnounce.disabled = !(previewedKey && previewedKey === key());
btnAnnounce.title = btnAnnounce.disabled ? 'Forhåndslytt først' : '';
}
async function api(path, body) {
const r = await fetch(path, { method: 'POST', headers: headers(), body: JSON.stringify(body || {}) });
if (!r.ok) {
let e = r.statusText; try { e = (await r.json()).error || e; } catch {}
throw new Error(e);
}
return r;
}
async function init() {
cfg = await (await fetch('/api/config')).json();
for (const v of cfg.voices) {
const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; voice.append(o);
}
voice.value = localStorage.getItem('voice') || cfg.default_voice;
$('#max').textContent = cfg.max_chars; text.maxLength = cfg.max_chars;
if (cfg.pin_required) { $('#pinbox').style.display = 'block'; $('#pin').value = localStorage.getItem('pin') || ''; }
for (const q of QUICK) {
const b = document.createElement('button'); b.className = 'chip'; b.textContent = q.slice(0, 32) + (q.length > 32 ? '…' : '');
b.title = q; b.onclick = () => { text.value = q; text.dispatchEvent(new Event('input')); text.focus(); };
$('#chips').append(b);
}
poll(); setInterval(poll, 2000);
}
text.addEventListener('input', () => { $('#count').textContent = text.value.length; updateAnnounce(); });
voice.addEventListener('change', () => { localStorage.setItem('voice', voice.value); updateAnnounce(); });
speed.addEventListener('input', () => { $('#speedval').textContent = parseFloat(speed.value).toFixed(2).replace(/0$/, '') + '×'; updateAnnounce(); });
chime.addEventListener('change', updateAnnounce);
$('#pin').addEventListener('input', e => localStorage.setItem('pin', e.target.value));
btnPreview.addEventListener('click', async () => {
const p = payload();
if (!p.text) return say('Skriv en melding først.', 'err');
btnPreview.disabled = true; say('Lager lyd …');
try {
const r = await api('/api/preview', p);
const blob = await r.blob();
if (audio.src) URL.revokeObjectURL(audio.src);
audio.src = URL.createObjectURL(blob); audio.classList.add('show');
await audio.play().catch(() => {});
previewedKey = JSON.stringify(p); updateAnnounce();
say('Hør gjennom. Fornøyd? Send den ut.', 'ok');
} catch (e) { say(e.message, 'err'); }
finally { btnPreview.disabled = false; }
});
btnAnnounce.addEventListener('click', async () => {
btnAnnounce.disabled = true;
try {
const item = await (await api('/api/announce', payload())).json();
say(`Lagt i kø som #${item.id}.`, 'ok');
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
audio.pause(); audio.classList.remove('show');
poll();
} catch (e) { say(e.message, 'err'); updateAnnounce(); }
});
$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(e => say(e.message, 'err')));
$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(e => say(e.message, 'err')));
const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø' };
function hhmm(ts) { return new Date(ts * 1000).toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' }); }
function li(it) {
const l = document.createElement('li'); l.className = it.status;
l.innerHTML = `<span class="t">${hhmm(it.created)}</span><span class="x"></span><span class="s">${STATUS[it.status] || it.status}${it.error ? ' · ' + it.error : ''}</span>`;
l.querySelector('.x').textContent = it.text;
return l;
}
async function poll() {
try {
const s = await (await fetch('/api/status')).json();
const now = $('#now');
if (s.current) { now.className = 'now'; now.innerHTML = '<span class="pulse"></span>'; now.append(document.createTextNode(s.current.text)); }
else { now.className = 'now idle'; now.textContent = ''; }
$('#queue').replaceChildren(...s.queued.map(li));
$('#history').replaceChildren(...s.history.map(li));
} catch {}
}
init();
</script>
</body>
</html>