announcer/index.html
Kent f191fb2b90 Døp om sg-announcer til announcer
Appen var navngitt etter Sauda Gathering, men det er ingenting
arrangementsspesifikt i selve koden. Navnet står igjen i tjenesten,
install.sh og pyproject.

- install.sh skriver nå announcer.service, og rydder bort en gammel
  sg-announcer.service først. Uten det slåss to enheter om port 8090
  når skriptet kjøres på en maskin som har den gamle.
- Enhetsfila får chmod 600; PIN-en står i klartekst i den.
- src/sg_announcer/ er slettet. Det var en uv-init-stub som bare printet
  «Hello from sg-announcer!», pekt på av [project.scripts], og som ingen
  har kalt noen gang.
- [tool.uv] package = false: appen er løse .py-filer som kjøres på plass,
  ikke en pakke. Uten den vil uv sync bygge et wheel og klage på at
  announcer/__init__.py mangler.

Repoet i Forgejo og mappa er døpt om tilsvarende.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-08 15:42:36 +02:00

239 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>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><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>
<label for="chime">Jingle først</label>
<select id="chime"></select>
</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.value };
}
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();
const groups = {};
for (const v of cfg.voices) {
if (!groups[v.group]) { groups[v.group] = document.createElement('optgroup'); groups[v.group].label = v.group; voice.append(groups[v.group]); }
const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; groups[v.group].append(o);
}
voice.value = localStorage.getItem('voice') || cfg.default_voice;
if (!voice.value) voice.value = cfg.default_voice;
for (const c of cfg.chimes) {
const o = document.createElement('option'); o.value = c.id; o.textContent = c.name; chime.append(o);
}
chime.value = localStorage.getItem('chime') || cfg.default_chime;
if (!chime.value) chime.value = cfg.default_chime;
$('#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', () => { localStorage.setItem('chime', chime.value); 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>