/* * operator.js — the sound desk console. * * Everything here is secondary to one thing: the big button has to be right, * and it has to tell the truth about whether the system is actually working. * A green "START" on a Sunday when the Mac engine is closed would be worse * than no button at all, so the button disables itself when there is no engine. */ (function () { 'use strict'; var $ = function (id) { return document.getElementById(id); }; var PIN_KEY = 'ct.pin'; var app = { pin: null, cfg: null, status: null, ws: null, base: location.origin, elapsedTimer: null }; var toastTimer = null; function toast(msg) { var t = $('toast'); t.textContent = msg; t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.hidden = true; }, 2600); } function api(path, opts) { opts = opts || {}; opts.headers = Object.assign({ 'X-Operator-Pin': app.pin }, opts.headers || {}); if (opts.body) opts.headers['Content-Type'] = 'application/json'; return fetch(path, opts).then(function (r) { return r.json().then(function (j) { if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status)); return j; }); }); } function save(patch, okMsg) { return api('/api/config', { method: 'POST', body: JSON.stringify(patch) }) .then(function (j) { app.cfg = j.config; toast(okMsg || 'Saved'); return j; }) .catch(function (e) { toast('Could not save: ' + e.message); throw e; }); } // ───────────────────────────────────────────── gate $('gateForm').addEventListener('submit', function (e) { e.preventDefault(); var pin = $('pin').value.trim(); if (!pin) return; app.pin = pin; api('/api/config') .then(function (j) { try { sessionStorage.setItem(PIN_KEY, pin); } catch (_) {} $('gateErr').hidden = true; enterConsole(j); }) .catch(function () { app.pin = null; $('gateErr').textContent = 'That PIN was not accepted.'; $('gateErr').hidden = false; $('pin').value = ''; $('pin').focus(); }); }); function enterConsole(j) { app.cfg = j.config; app.status = j.status; app.base = (j.config.publicUrl || j.publicBase || location.origin).replace(/\/+$/, ''); $('gate').classList.remove('is-active'); $('console').classList.add('is-active'); $('totalLangs').textContent = Languages.LANGS.length; renderConfig(); renderStatus(j.status); connect(); } // ───────────────────────────────────────────── status + big button function fmtElapsed(since) { if (!since) return '—'; var s = Math.max(0, Math.floor((Date.now() - since) / 1000)); var m = Math.floor(s / 60); return m + ':' + String(s % 60).padStart(2, '0'); } /* One chip per language actually being read, busiest first. */ function renderWho(rows) { var box = $('whoList'); box.textContent = ''; if (!rows.length) { box.hidden = true; return; } box.hidden = false; rows.forEach(function (row) { var l = Languages.get(row.code); var el = document.createElement('span'); el.className = 'who'; var n = document.createElement('b'); n.textContent = row.count; var name = document.createElement('span'); name.textContent = l ? l.native : row.code; el.appendChild(n); el.appendChild(name); box.appendChild(el); }); } function renderStatus(st) { if (!st) return; app.status = st; $('svcName').textContent = st.serviceName || 'Live Translation'; /* The traffic light — computed on the server so every surface agrees. */ var hl = $('opHealth'); if (hl) { hl.hidden = !st.health; if (st.health) { hl.setAttribute('data-level', st.health.level); hl.textContent = st.health.detail; } } var live = !!st.live; var ready = !!st.engineConnected; var btn = $('bigBtn'); btn.dataset.live = live ? 'true' : 'false'; btn.disabled = !ready && !live; btn.querySelector('.big-label').textContent = live ? 'STOP TRANSLATING' : 'START TRANSLATING'; var stale = !!st.engineStale; $('bigSub').textContent = stale ? 'The Mac engine has stopped responding' : live ? 'On air — the congregation is reading' : (ready ? 'Engine ready' : 'Waiting for the Mac engine'); /* Connected-but-silent is the dangerous state: the socket is up, so everything looks healthy, but nothing is being transcribed. */ $('engineWarn').hidden = ready && !stale; if (stale) { $('engineWarn').querySelector('strong').textContent = 'The Mac engine is connected but silent.'; $('engineWarn').querySelector('span').textContent = 'It has not reported for over 10 seconds. Quit and reopen Church Translate Engine on the Mac Studio.'; } else if (!ready) { $('engineWarn').querySelector('strong').textContent = 'The Mac engine is not connected.'; $('engineWarn').querySelector('span').textContent = 'Open Church Translate Engine on the backstage Mac Studio. Nothing will translate until it is running.'; } $('stListeners').textContent = st.listeners || 0; $('stLangs').textContent = (st.activeLanguages || []).length; $('stElapsed').textContent = fmtElapsed(st.startedAt); renderWho(st.listenersByLanguage || []); clearInterval(app.elapsedTimer); if (live && st.startedAt) { app.elapsedTimer = setInterval(function () { $('stElapsed').textContent = fmtElapsed(st.startedAt); }, 1000); } var es = st.engineStatus || {}; var bits = []; if (es.device) bits.push('🎙 ' + es.device); if (es.model) bits.push('🧠 ' + es.model); if (typeof es.peakDb === 'number' && es.peakDb > -99) { /* Peak over the last few seconds — an instantaneous sample lands between syllables and reads as silence on a working feed. */ bits.push('📶 ' + es.peakDb + ' dB peak' + (es.peakDb < -40 ? ' (low)' : '')); } else if (typeof es.rms === 'number') { bits.push('📶 ' + (es.rms > 0.002 ? 'audio present' : 'silent')); } if (typeof es.lagMs === 'number') bits.push('⏱ ' + Math.round(es.lagMs) + ' ms behind'); $('engineInfo').textContent = bits.join(' '); $('engineInfo').hidden = bits.length === 0; } $('bigBtn').addEventListener('click', function () { if (!app.ws || app.ws.readyState !== 1) { toast('Not connected to the server.'); return; } app.ws.send(JSON.stringify({ t: 'toggle' })); }); function connect() { var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; var ws = new WebSocket(proto + '//' + location.host + '/ws?role=operator&pin=' + encodeURIComponent(app.pin)); app.ws = ws; ws.onmessage = function (ev) { var m; try { m = JSON.parse(ev.data); } catch (_) { return; } if (m.t === 'status') renderStatus(m.status); }; ws.onclose = function () { if (app.ws !== ws) return; app.ws = null; setTimeout(connect, 2000); }; } // ───────────────────────────────────────────── languages function chip(code, onRemove) { var l = Languages.get(code) || { native: code, name: code }; var el = document.createElement('span'); el.className = 'chip'; el.textContent = l.native === l.name ? l.native : l.native + ' · ' + l.name; var x = document.createElement('button'); x.type = 'button'; x.setAttribute('aria-label', 'Remove ' + l.name); x.textContent = '×'; x.addEventListener('click', function () { onRemove(code); }); el.appendChild(x); return el; } function renderConfig() { var cfg = app.cfg; var f = $('featuredList'); f.textContent = ''; (cfg.featured || []).forEach(function (code) { f.appendChild(chip(code, function (c) { save({ featured: cfg.featured.filter(function (x) { return x !== c; }) }) .then(renderConfig); })); }); var p = $('pinnedList'); p.textContent = ''; (cfg.pinned || []).forEach(function (code) { p.appendChild(chip(code, function (c) { save({ pinned: cfg.pinned.filter(function (x) { return x !== c; }) }) .then(renderConfig); })); }); $('bias').value = ((cfg.glossary && cfg.glossary.fixes) || []).join('\n'); $('publicUrl').value = cfg.publicUrl || ''; var base = (cfg.publicUrl || app.base).replace(/\/+$/, ''); $('qr').src = '/qr.svg?url=' + encodeURIComponent(base + '/'); $('posterLink').href = '/poster?pin=' + encodeURIComponent(app.pin); var key = encodeURIComponent(cfg.controlToken); $('urlToggle').textContent = base + '/api/control/toggle?key=' + key; $('urlStart').textContent = base + '/api/control/start?key=' + key; $('urlStop').textContent = base + '/api/control/stop?key=' + key; } var searchTimer = null; $('langSearch').addEventListener('input', function (e) { var q = e.target.value; clearTimeout(searchTimer); searchTimer = setTimeout(function () { renderLangResults(q); }, 90); }); function renderLangResults(q) { var box = $('langResults'); box.textContent = ''; if (!q.trim()) return; Languages.search(q).slice(0, 25).forEach(function (l) { var b = document.createElement('button'); b.type = 'button'; b.textContent = l.native; var s = document.createElement('span'); s.textContent = l.name; b.appendChild(s); b.addEventListener('click', function () { var featured = (app.cfg.featured || []).slice(); if (featured.indexOf(l.code) === -1) featured.push(l.code); save({ featured: featured }, l.name + ' added').then(function () { $('langSearch').value = ''; box.textContent = ''; renderConfig(); }); }); box.appendChild(b); }); } // ───────────────────────────────────────────── the rest $('saveGlossary').addEventListener('click', function () { var lines = $('bias').value.split('\n').map(function (s) { return s.trim(); }).filter(Boolean); /* Warn rather than silently drop: a line without "=>" is not a correction and would otherwise look saved while doing nothing. */ var bad = lines.filter(function (l) { return l.indexOf('=>') === -1; }); var good = lines.filter(function (l) { return l.indexOf('=>') !== -1; }); var glossary = Object.assign({}, app.cfg.glossary, { fixes: good }); save({ glossary: glossary }, bad.length ? (good.length + ' saved · ' + bad.length + ' line(s) ignored, they need "=>"') : (good.length + ' corrections saved')); }); $('savePublic').addEventListener('click', function () { save({ publicUrl: $('publicUrl').value.trim() }, 'Address saved').then(renderConfig); }); $('savePin').addEventListener('click', function () { var pin = $('newPin').value.trim(); if (pin.length < 4) { toast('Use at least 4 characters.'); return; } save({ operatorPin: pin }, 'PIN updated').then(function () { app.pin = pin; try { sessionStorage.setItem(PIN_KEY, pin); } catch (_) {} $('newPin').value = ''; renderConfig(); }); }); document.addEventListener('click', function (e) { var btn = e.target.closest('.copy'); if (!btn) return; var text = $(btn.dataset.copy).textContent; navigator.clipboard.writeText(text) .then(function () { toast('Copied'); }) .catch(function () { toast('Copy failed — select it by hand'); }); }); // Resume a session on reload without retyping the PIN. var saved = null; try { saved = sessionStorage.getItem(PIN_KEY); } catch (_) {} if (saved) { app.pin = saved; api('/api/config').then(enterConsole).catch(function () { app.pin = null; try { sessionStorage.removeItem(PIN_KEY); } catch (_) {} $('pin').focus(); }); } else { $('pin').focus(); } })();