Sju ting fra en gjennomgang av det som ble bygd i går. Stopp virket ikke mens lyden ble laget. Player.stop() drepte self.proc, men under syntesen finnes ingen prosess ennå – det er de første sekundene av hver edge-tts-melding – så knappen gjorde ingenting, og meldingen gikk ut etterpå. Nå har køen statusen preparing, stop() setter stopped på den, og run_once() sjekker det før den starter avspilling. UI-et viser «Lager lyd …» i stedet for å late som det spilles i stillhet. Planlagte sendinger løy om status. De ble markert «sendt» idet de ble lagt i kø, og ble stående slik selv om avspillingen feilet. Player har fått en on_finished-callback, og planen følger nå meldingen helt i mål: sent, failed eller stopped – det som faktisk skjedde. Statusene i schedule.py er utvidet tilsvarende (queued er «lagt i kø», sent er «spilt ferdig»). Flask sin utviklingsserver er byttet ut med waitress. Én prosess med mange tråder, med vilje: Player og planleggeren er singletons, så gunicorn med flere workere ville gitt flere køer som snakker i munnen på hverandre. HTTP-laget hadde null tester. import announcer startet tråder og dro inn Piper, så rutene lot seg ikke prøve. Trådene startes nå i main(), og run_once() driver køen ett steg om gangen. tests/test_routes.py dekker rutene, valideringen, køen, Stopp i begge faser, revisjonsloggen og hele planleggingsflyten gjennom HTTP. 30 nye tester, 110 totalt. Kappløp i synthesize(): tmp-fila hadde fast navn, så to som forhåndslyttet samme tekst samtidig skrev til samme fil, og den ene rename feilet. Unik tmp-fil per kall. verify_signature = false er fjernet. Det var en felle: én feilskrevet linje i config, og alle tokens ble godtatt. Ingen trenger den. docker compose up uten install.sh krasjet kryptisk ved første utrop, fordi Docker lagde data/ som root. check_writable() sier nå klart fra ved oppstart, med hva som må gjøres. Verifisert live i container: Stopp under edge-tts-syntese gir stopped, ikke done. Server-headeren er waitress sin, ikke werkzeugs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
496 lines
22 KiB
HTML
496 lines
22 KiB
HTML
<!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; }
|
||
.top { display: flex; gap: 12px; align-items: flex-start; justify-content: space-between; }
|
||
.who { font-size: 13px; color: var(--muted); text-align: right; white-space: nowrap; padding-top: 2px; }
|
||
.who b { color: var(--fg); font-weight: 600; }
|
||
.who a { color: var(--accent); text-decoration: none; }
|
||
.who a:hover { text-decoration: underline; }
|
||
.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; }
|
||
.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; }
|
||
a.btn { text-decoration: none; display: inline-block; text-align: center; }
|
||
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); }
|
||
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 .by { color: var(--muted); font-size: 12px; }
|
||
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 } }
|
||
#loginbox, #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%; }
|
||
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||
.hidden { display: none !important; }
|
||
.when { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
|
||
.when input[type=datetime-local] { background: var(--panel2); color: var(--fg); border: 1px solid var(--line);
|
||
border-radius: 8px; padding: 10px; font: inherit; }
|
||
.when button { flex: 0 1 auto; padding: 10px 14px; font-size: 14px; }
|
||
.when .q { display: flex; gap: 6px; }
|
||
.when .q button { background: var(--panel2); border: 1px solid var(--line); color: var(--fg);
|
||
border-radius: 999px; padding: 6px 10px; font: inherit; font-size: 13px; cursor: pointer; }
|
||
.when .q button:hover { border-color: var(--accent); }
|
||
ul.list li button.x { background: none; border: none; color: var(--muted); cursor: pointer;
|
||
font: inherit; font-size: 13px; padding: 0 4px; }
|
||
ul.list li button.x:hover { color: var(--danger); }
|
||
ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); }
|
||
.now .t { color: var(--muted); font-size: 13px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main>
|
||
<div class="top">
|
||
<div>
|
||
<h1 id="title"></h1>
|
||
<p class="sub" id="subtitle"></p>
|
||
</div>
|
||
<div class="who" id="who"></div>
|
||
</div>
|
||
|
||
<div class="card" id="loginbox">
|
||
<div id="loginbtns" class="actions" style="margin-top:0"></div>
|
||
</div>
|
||
|
||
<div class="card" id="pinbox">
|
||
<label for="pin" id="pinlabel">PIN</label>
|
||
<input id="pin" type="password" inputmode="numeric" autocomplete="current-password">
|
||
<div class="hint" id="pinhint"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<label for="text">Melding</label>
|
||
<textarea id="text" 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>
|
||
<div class="when" id="whenbox">
|
||
<input id="at" type="datetime-local" step="60">
|
||
<div class="q" id="atquick"></div>
|
||
<button class="btn" id="btnSchedule" disabled>🕑 Planlegg</button>
|
||
</div>
|
||
<audio id="player" controls></audio>
|
||
<div class="msg" id="msg"></div>
|
||
</div>
|
||
|
||
<div class="card hidden" id="schedcard">
|
||
<div class="status-head"><h2>Planlagt</h2></div>
|
||
<ul class="list" id="sched"></ul>
|
||
</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 = {}, can = [], previewedKey = null, pollTimer = null, me = null, skew = 0;
|
||
|
||
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(json) {
|
||
const h = json ? { 'Content-Type': 'application/json' } : {};
|
||
// PIN-en er den eneste legitimasjonen som ligger i nettleseren; OIDC og
|
||
// token-lenka bruker sesjonscookien, som følger med av seg selv.
|
||
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 may(action) { return can.includes(action); }
|
||
function updateAnnounce() {
|
||
const ready = previewedKey && previewedKey === key();
|
||
btnAnnounce.disabled = !ready || !may('announce');
|
||
btnAnnounce.title = !may('announce') ? 'Du har ikke lov til å sende ut' :
|
||
(btnAnnounce.disabled ? 'Forhåndslytt først' : '');
|
||
btnPreview.disabled = !may('preview');
|
||
btnPreview.title = may('preview') ? '' : 'Du har ikke lov til å forhåndslytte';
|
||
|
||
// Planlegging er låst til forhåndslytten på samme måte som «Send ut»:
|
||
// ingen skal legge noe i kalenderen de ikke har hørt.
|
||
const at = $('#at').value;
|
||
$('#whenbox').classList.toggle('hidden', !may('schedule'));
|
||
$('#btnSchedule').disabled = !ready || !at || !may('schedule');
|
||
$('#btnSchedule').title = !at ? 'Velg tidspunkt' :
|
||
(!ready ? 'Forhåndslytt først' : '');
|
||
}
|
||
|
||
// Serverens klokke gjelder. Containeren kan stå i UTC mens nettleseren står
|
||
// i CEST, og da ville «om 20 minutter» blitt to timer feil.
|
||
function serverNow() { return Date.now() / 1000 + skew; }
|
||
|
||
function atEpoch() {
|
||
const v = $('#at').value;
|
||
if (!v) return null;
|
||
const t = new Date(v).getTime(); // datetime-local tolkes lokalt
|
||
return Number.isFinite(t) ? t / 1000 : null;
|
||
}
|
||
|
||
function omTid(ts) {
|
||
const s = ts - serverNow();
|
||
if (s < 0) return 'nå';
|
||
if (s < 90) return 'om ' + Math.round(s) + ' s';
|
||
if (s < 5400) return 'om ' + Math.round(s / 60) + ' min';
|
||
if (s < 86400) return 'om ' + (s / 3600).toFixed(1).replace('.0', '') + ' t';
|
||
return 'om ' + Math.round(s / 86400) + ' d';
|
||
}
|
||
|
||
function setAtIn(minutes) {
|
||
const d = new Date(Date.now() + minutes * 60000);
|
||
d.setSeconds(0, 0);
|
||
// datetime-local vil ha lokal tid uten sone, ikke ISO i UTC.
|
||
const pad = v => String(v).padStart(2, '0');
|
||
$('#at').value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||
updateAnnounce();
|
||
}
|
||
|
||
async function req(path, opts) {
|
||
const r = await fetch(path, opts);
|
||
if (!r.ok) {
|
||
let e = r.statusText, login = false;
|
||
try { const j = await r.json(); e = j.error || e; login = !!j.login; } catch {}
|
||
const err = new Error(e);
|
||
err.status = r.status; err.login = login;
|
||
throw err;
|
||
}
|
||
return r;
|
||
}
|
||
const api = (path, body) =>
|
||
req(path, { method: 'POST', headers: headers(true), body: JSON.stringify(body || {}) });
|
||
|
||
function onError(e) {
|
||
say(e.message, 'err');
|
||
// Rettighetene kan ha endret seg (riktig PIN skrevet, sesjon utløpt).
|
||
if (e.status === 401 || e.status === 403) refreshAuth();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- branding
|
||
function applyConfig(c) {
|
||
cfg = c;
|
||
const a = c.app || {};
|
||
document.title = a.title || 'Announcer';
|
||
// Siste ord i tittelen får aksentfargen; ett ord alene står som det er.
|
||
const parts = (a.title || 'Announcer').split(' ');
|
||
$('#title').textContent = '';
|
||
if (parts.length > 1) {
|
||
$('#title').append(document.createTextNode(parts.slice(0, -1).join(' ') + ' '));
|
||
const sp = document.createElement('span'); sp.textContent = parts.at(-1); $('#title').append(sp);
|
||
} else {
|
||
$('#title').textContent = parts[0];
|
||
}
|
||
$('#subtitle').textContent = a.subtitle || '';
|
||
if (a.accent) document.documentElement.style.setProperty('--accent', a.accent);
|
||
text.placeholder = a.message_placeholder || '';
|
||
$('#max').textContent = c.max_chars; text.maxLength = c.max_chars;
|
||
|
||
$('#chips').replaceChildren(...(a.quick_phrases || []).map(q => {
|
||
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(); };
|
||
return b;
|
||
}));
|
||
}
|
||
|
||
// -------------------------------------------------------------------- auth
|
||
function applyAuth(auth) {
|
||
can = auth.can || [];
|
||
const methods = auth.methods || [];
|
||
const who = $('#who');
|
||
|
||
if (auth.user) {
|
||
who.textContent = '';
|
||
who.append(document.createTextNode('Innlogget som '));
|
||
const b = document.createElement('b'); b.textContent = auth.user.name; who.append(b);
|
||
if (auth.user.roles.length) who.append(document.createTextNode(' (' + auth.user.roles.join(', ') + ')'));
|
||
if (auth.logout_url) {
|
||
who.append(document.createTextNode(' · '));
|
||
const a = document.createElement('a'); a.href = auth.logout_url; a.textContent = 'Logg ut'; who.append(a);
|
||
}
|
||
} else if (auth.enabled) {
|
||
who.textContent = 'Ikke innlogget';
|
||
} else {
|
||
who.textContent = '';
|
||
}
|
||
|
||
// OIDC-knapper
|
||
const oidc = methods.filter(m => m.login_url);
|
||
$('#loginbox').style.display = (oidc.length && !auth.user) ? 'block' : 'none';
|
||
$('#loginbtns').replaceChildren(...oidc.map(m => {
|
||
const a = document.createElement('a');
|
||
a.className = 'btn primary'; a.href = m.login_url; a.textContent = m.label || 'Logg inn';
|
||
return a;
|
||
}));
|
||
|
||
// PIN-feltet, bare når PIN er en av metodene og vi ikke alt er innlogget
|
||
const pin = methods.find(m => m.method === 'pin');
|
||
$('#pinbox').style.display = (pin && !auth.user) ? 'block' : 'none';
|
||
if (pin) {
|
||
$('#pinlabel').textContent = pin.label || 'PIN';
|
||
$('#pinhint').textContent = auth.error || pin.hint || '';
|
||
$('#pinhint').style.color = auth.error ? 'var(--danger)' : 'var(--muted)';
|
||
if (!$('#pin').value) $('#pin').value = localStorage.getItem('pin') || '';
|
||
}
|
||
|
||
me = auth.user;
|
||
$('#btnStop').classList.toggle('hidden', !may('stop'));
|
||
$('#btnClear').classList.toggle('hidden', !may('clear'));
|
||
updateAnnounce();
|
||
}
|
||
|
||
async function refreshAuth() {
|
||
try {
|
||
const c = await (await fetch('/api/config', { headers: headers(false) })).json();
|
||
applyAuth(c.auth || {});
|
||
} catch {}
|
||
}
|
||
|
||
// -------------------------------------------------------------------- init
|
||
async function init() {
|
||
const c = await (await fetch('/api/config', { headers: headers(false) })).json();
|
||
applyConfig(c);
|
||
|
||
const groups = {};
|
||
for (const v of c.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') || c.default_voice;
|
||
if (!voice.value) voice.value = c.default_voice;
|
||
for (const ch of c.chimes) {
|
||
const o = document.createElement('option'); o.value = ch.id; o.textContent = ch.name; chime.append(o);
|
||
}
|
||
chime.value = localStorage.getItem('chime') || c.default_chime;
|
||
if (!chime.value) chime.value = c.default_chime;
|
||
|
||
applyAuth(c.auth || {});
|
||
$('#atquick').replaceChildren(...[
|
||
['+5 min', 5], ['+15 min', 15], ['+1 t', 60], ['+3 t', 180],
|
||
].map(([label, min]) => {
|
||
const b = document.createElement('button');
|
||
b.textContent = label; b.onclick = () => setAtIn(min);
|
||
return b;
|
||
}));
|
||
poll();
|
||
pollTimer = setInterval(poll, 2000);
|
||
}
|
||
|
||
$('#at').addEventListener('input', updateAnnounce);
|
||
|
||
$('#btnSchedule').addEventListener('click', async () => {
|
||
const at = atEpoch();
|
||
if (!at) return say('Velg et tidspunkt først.', 'err');
|
||
if (at < serverNow()) return say('Tidspunktet er passert.', 'err');
|
||
$('#btnSchedule').disabled = true;
|
||
try {
|
||
const item = await (await api('/api/schedule', { ...payload(), at })).json();
|
||
say(`Planlagt ${new Date(item.at * 1000).toLocaleString('no', {
|
||
weekday: 'short', hour: '2-digit', minute: '2-digit' })}.`, 'ok');
|
||
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
|
||
$('#at').value = '';
|
||
audio.pause(); audio.classList.remove('show');
|
||
poll();
|
||
} catch (e) { onError(e); }
|
||
finally { updateAnnounce(); }
|
||
});
|
||
|
||
async function cancelSched(id, what) {
|
||
if (!confirm(`Avbryte den planlagte meldingen?\n\n${what}`)) return;
|
||
try { await api('/api/schedule/cancel', { id }); say('Avbrutt.', 'ok'); poll(); }
|
||
catch (e) { onError(e); }
|
||
}
|
||
|
||
let pinDebounce = null;
|
||
$('#pin').addEventListener('input', e => {
|
||
localStorage.setItem('pin', e.target.value);
|
||
clearTimeout(pinDebounce);
|
||
// Vent til han er ferdig å skrive, så slipper vi en runde per tastetrykk.
|
||
pinDebounce = setTimeout(() => { refreshAuth(); poll(); }, 600);
|
||
});
|
||
|
||
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(); });
|
||
|
||
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);
|
||
say('Hør gjennom. Fornøyd? Send den ut.', 'ok');
|
||
} catch (e) { onError(e); }
|
||
finally { btnPreview.disabled = false; updateAnnounce(); }
|
||
});
|
||
|
||
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) { onError(e); }
|
||
finally { updateAnnounce(); }
|
||
});
|
||
|
||
$('#btnStop').addEventListener('click', () => api('/api/stop').then(poll).catch(onError));
|
||
$('#btnClear').addEventListener('click', () => api('/api/clear').then(poll).catch(onError));
|
||
|
||
const STATUS = { done: 'sendt', failed: 'feilet', stopped: 'stoppet', queued: 'i kø',
|
||
preparing: 'lager lyd', playing: 'spiller' };
|
||
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"></span><span class="x"></span><span class="by"></span><span class="s"></span>`;
|
||
l.querySelector('.t').textContent = hhmm(it.created);
|
||
l.querySelector('.x').textContent = it.text;
|
||
// Navnet står her, ikke IP-en. IP-en kommer bare med for en admin.
|
||
l.querySelector('.by').textContent = [it.user, it.sender].filter(Boolean).join(' · ');
|
||
l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : '');
|
||
return l;
|
||
}
|
||
const SCHED_STATUS = { queued: 'i kø', sent: 'sendt', failed: 'feilet', stopped: 'stoppet',
|
||
missed: 'gikk glipp av', cancelled: 'avbrutt' };
|
||
function schedLi(it) {
|
||
const l = document.createElement('li');
|
||
l.className = it.status;
|
||
l.innerHTML = '<span class="t"></span><span class="x"></span><span class="by"></span><span class="s"></span>';
|
||
const when = new Date(it.at * 1000);
|
||
l.querySelector('.t').textContent = when.toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' });
|
||
l.querySelector('.x').textContent = it.text;
|
||
l.querySelector('.by').textContent = it.user || '';
|
||
const s = l.querySelector('.s');
|
||
if (it.status === 'pending') {
|
||
s.textContent = omTid(it.at) + ' ';
|
||
// Egne kan man alltid avbryte; andres krever samme rett som å tømme kø.
|
||
if (may('schedule') && (!me || !it.user || may('clear') || it.user === me.name)) {
|
||
const b = document.createElement('button');
|
||
b.className = 'x'; b.textContent = '✕'; b.title = 'Avbryt';
|
||
b.onclick = () => cancelSched(it.id, it.text);
|
||
s.append(b);
|
||
}
|
||
} else {
|
||
s.textContent = (SCHED_STATUS[it.status] || it.status) + (it.note ? ' · ' + it.note : '');
|
||
}
|
||
return l;
|
||
}
|
||
|
||
async function poll() {
|
||
const now = $('#now');
|
||
try {
|
||
const s = await (await req('/api/status', { headers: headers(false) })).json();
|
||
if (s.now) skew = s.now - Date.now() / 1000;
|
||
const sc = s.scheduled || { pending: [], done: [] };
|
||
const rows = [...sc.pending, ...sc.done];
|
||
$('#schedcard').classList.toggle('hidden', rows.length === 0);
|
||
$('#sched').replaceChildren(...rows.map(schedLi));
|
||
if (s.current) {
|
||
// «Lager lyd» er de første sekundene av hver edge-tts-melding: det
|
||
// er stille, og uten dette så det ut som avspillingen hang.
|
||
const preparing = s.current.status === 'preparing';
|
||
now.className = 'now' + (preparing ? ' idle' : '');
|
||
now.innerHTML = preparing ? '<span class="t">Lager lyd … </span>' : '<span class="pulse"></span>';
|
||
now.append(document.createTextNode(s.current.text));
|
||
}
|
||
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
|
||
$('#queue').replaceChildren(...s.queued.map(li));
|
||
$('#history').replaceChildren(...s.history.map(li));
|
||
} catch (e) {
|
||
if (e.status === 401 || e.status === 403) {
|
||
now.className = 'now idle';
|
||
now.textContent = 'Logg inn for å se køen.';
|
||
$('#queue').replaceChildren(); $('#history').replaceChildren();
|
||
}
|
||
}
|
||
}
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|