Church Translate, as it runs at the campuses

Live sermon translation into 100+ languages, running entirely on one Mac per
campus: audio in, voice detection, Whisper on the GPU, NLLB-200 in its own
process, and a built-in web server the congregation reads from on their
phones. No audio leaves the building.

This is the source only. What used to sit beside it — 14 GB of speech and
translation models, 8 GB of voice bundles, 6 GB of natural voices, the Electron
caches, and the app data holding the operator PIN and every service transcript
— is machine state and downloaded assets, and stays in the NAS backup where it
belongs. The vendored node, cloudflared and Windows ffmpeg binaries are out too
(533 MB, re-fetched for packaging); the browser-side runtime beside them is
kept, so a checkout still serves the congregation page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 15:41:21 -04:00
co-authored by Claude Opus 5
commit c9eef0672c
118 changed files with 36031 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "Live Translation",
"short_name": "Translate",
"description": "Live sermon translation in your language",
"start_url": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0b0d12",
"theme_color": "#0b0d12",
"icons": [
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }
]
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#0b0d12"/>
<g fill="none" stroke="#6ea8fe" stroke-width="26" stroke-linecap="round" stroke-linejoin="round">
<path d="M104 168h150M179 136v32M214 168c0 62-45 112-100 130"/>
<path d="M150 232c14 30 40 54 74 66"/>
<path d="M266 400l68-168 68 168M292 344h84"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 397 B

+89
View File
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5">
<meta name="theme-color" content="#0b0d12">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="color-scheme" content="dark">
<title>Live Translation</title>
<link rel="manifest" href="/app.webmanifest">
<link rel="stylesheet" href="/listen.css">
</head>
<body>
<!-- ───────────────────────── language picker ───────────────────────── -->
<section id="picker" class="screen is-active">
<div class="picker-inner">
<header class="picker-head">
<div class="church" id="churchName">Live Translation</div>
<h1 id="chooseTitle">Choose your language</h1>
<!-- Cycles through the same question in other languages, so anyone can
tell what this page is for without reading English. -->
<div class="rotator" id="rotator" aria-hidden="true"></div>
</header>
<div class="featured" id="featured"></div>
<div class="search-wrap">
<div class="search-box">
<input id="search" type="search" inputmode="search" autocomplete="off" enterkeyhint="search"
autocorrect="off" spellcheck="false" placeholder="Search all languages">
<button id="searchClear" class="search-clear" type="button" aria-label="Clear search" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<div class="results" id="results" role="listbox"></div>
</div>
</div>
</section>
<!-- ───────────────────────── caption reader ────────────────────────── -->
<section id="reader" class="screen">
<header class="bar">
<button id="btnBack" class="bar-btn" aria-label="Change language">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>
</button>
<div class="bar-lang">
<span id="barLangName">—</span>
<span id="liveDot" class="dot" title="Live"></span>
</div>
<div class="bar-actions">
<button id="btnSpeak" class="bar-btn" aria-label="Speak aloud" aria-pressed="false">
<svg viewBox="0 0 24 24" aria-hidden="true" class="ic-off"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M22 9l-6 6M16 9l6 6"/></svg>
<svg viewBox="0 0 24 24" aria-hidden="true" class="ic-on"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7M18.5 5.5a9 9 0 010 13"/></svg>
</button>
<button id="btnOriginal" class="bar-btn bar-btn-text bar-btn-src" aria-label="Show original" aria-pressed="false">EN</button>
<button id="btnSize" class="bar-btn bar-btn-text" aria-label="Text size">Aa</button>
</div>
</header>
<div id="voiceBar" class="voicebar" hidden>
<label for="voicePick">Voice</label>
<select id="voicePick"></select>
</div>
<p id="voiceHint" class="voicehint" hidden></p>
<main id="stream" class="stream" tabindex="0">
<div id="waiting" class="waiting">
<div class="pulse"><span></span><span></span><span></span></div>
<p id="waitingText">The message hasn't started yet</p>
</div>
<div id="lines" class="lines"></div>
<div id="partial" class="line line-partial" hidden></div>
</main>
<button id="jumpLive" class="jump" hidden>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg>
<span id="jumpLabel">Live</span>
</button>
<div id="toast" class="toast" hidden></div>
</section>
<script src="/languages.js"></script>
<script src="/ui-strings.js"></script>
<script src="/listen.js"></script>
</body>
</html>
+186
View File
@@ -0,0 +1,186 @@
/*
* languages.js — the language catalog, shared by the NAS server, the listener
* page, and the Mac engine.
*
* `code` is a FLORES-200 code, which is what NLLB-200 expects. `tts` is the
* BCP-47 tag we hand to the phone's speechSynthesis; it is a best effort, and a
* phone that has no matching voice simply shows captions without speaking.
*
* `native` is what the listener actually sees in the picker. Never show someone
* the English name of their own language when you can help it.
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.Languages = factory();
}(typeof self !== 'undefined' ? self : this, function () {
// [ FLORES code, English name, native name, BCP-47 for TTS, rtl ]
var RAW = [
['eng_Latn', 'English', 'English', 'en-US'],
['spa_Latn', 'Spanish (Latin America)', 'Español (América Latina)', 'es-MX'],
['zho_Hans', 'Chinese (Simplified)', '简体中文', 'zh-CN'],
['zho_Hant', 'Chinese (Traditional)','繁體中文', 'zh-TW'],
['yue_Hant', 'Cantonese', '廣東話', 'zh-HK'],
['fra_Latn', 'French', 'Français', 'fr-FR'],
/* One NLLB Portuguese, two voices: the Brazilian and European variants
share their translation (por_PT is aliased to por_Latn at the MT
boundary in engine.js) and differ in the voice that reads it. */
['por_Latn', 'Portuguese (Brazil)', 'Português (Brasil)', 'pt-BR'],
['por_PT', 'Portuguese (Portugal)', 'Português (Portugal)', 'pt-PT'],
/* Same NLLB Spanish, read in a Castilian voice. There is no grammatical
transform between them: converting ustedes->vosotros needs to know
whether "you" was one person or a room, and neither the English nor
the Spanish carries that — see spa-variants.js for the measurements. */
['spa_ES', 'Spanish (Spain)', 'Español (España)', 'es-ES'],
/* Mexico shares Latin America's grammar exactly (RAE: vosotros "carecen
de uso en América", and Mexico is tuteo-exclusive), so the text is
identical — this exists so a Mexican listener sees their own country
named, and so the operator can give it its own voice. */
['spa_MX', 'Spanish (Mexico)', 'Español (México)', 'es-MX'],
['kor_Hang', 'Korean', '한국어', 'ko-KR'],
['vie_Latn', 'Vietnamese', 'Tiếng Việt', 'vi-VN'],
['hat_Latn', 'Haitian Creole', 'Kreyòl Ayisyen', 'ht-HT'],
['arb_Arab', 'Arabic', 'العربية', 'ar-SA', true],
['rus_Cyrl', 'Russian', 'Русский', 'ru-RU'],
['ukr_Cyrl', 'Ukrainian', 'Українська', 'uk-UA'],
['ron_Latn', 'Romanian', 'Română', 'ro-RO'],
['pol_Latn', 'Polish', 'Polski', 'pl-PL'],
['deu_Latn', 'German', 'Deutsch', 'de-DE'],
['ita_Latn', 'Italian', 'Italiano', 'it-IT'],
['nld_Latn', 'Dutch', 'Nederlands', 'nl-NL'],
['tgl_Latn', 'Tagalog', 'Tagalog', 'fil-PH'],
['ceb_Latn', 'Cebuano', 'Cebuano', 'fil-PH'],
['ind_Latn', 'Indonesian', 'Bahasa Indonesia', 'id-ID'],
['zsm_Latn', 'Malay', 'Bahasa Melayu', 'ms-MY'],
['tha_Thai', 'Thai', 'ไทย', 'th-TH'],
['khm_Khmr', 'Khmer', 'ភាសាខ្មែរ', 'km-KH'],
['lao_Laoo', 'Lao', 'ລາວ', 'lo-LA'],
['mya_Mymr', 'Burmese', 'မြန်မာ', 'my-MM'],
['shn_Mymr', 'Shan', ' လိၵ်ႈတႆး', 'shn'],
['kac_Latn', 'Kachin (Jingpho)', 'Jinghpaw', 'kac'],
['npi_Deva', 'Nepali', 'नेपाली', 'ne-NP'],
['hin_Deva', 'Hindi', 'हिन्दी', 'hi-IN'],
['urd_Arab', 'Urdu', 'اردو', 'ur-PK', true],
['ben_Beng', 'Bengali', 'বাংলা', 'bn-BD'],
['pan_Guru', 'Punjabi', 'ਪੰਜਾਬੀ', 'pa-IN'],
['guj_Gujr', 'Gujarati', 'ગુજરાતી', 'gu-IN'],
['tam_Taml', 'Tamil', 'தமிழ்', 'ta-IN'],
['tel_Telu', 'Telugu', 'తెలుగు', 'te-IN'],
['mal_Mlym', 'Malayalam', 'മലയാളം', 'ml-IN'],
['kan_Knda', 'Kannada', 'ಕನ್ನಡ', 'kn-IN'],
['mar_Deva', 'Marathi', 'मराठी', 'mr-IN'],
['sin_Sinh', 'Sinhala', 'සිංහල', 'si-LK'],
['pes_Arab', 'Persian (Farsi)', 'فارسی', 'fa-IR', true],
['prs_Arab', 'Dari', 'دری', 'fa-AF', true],
['pbt_Arab', 'Pashto', 'پښتو', 'ps-AF', true],
['ckb_Arab', 'Kurdish (Sorani)', 'کوردی', 'ckb', true],
['kmr_Latn', 'Kurdish (Kurmanji)', 'Kurmancî', 'ku'],
['heb_Hebr', 'Hebrew', 'עברית', 'he-IL', true],
['tur_Latn', 'Turkish', 'Türkçe', 'tr-TR'],
['amh_Ethi', 'Amharic', 'አማርኛ', 'am-ET'],
['tir_Ethi', 'Tigrinya', 'ትግርኛ', 'ti-ET'],
['som_Latn', 'Somali', 'Soomaali', 'so-SO'],
['swh_Latn', 'Swahili', 'Kiswahili', 'sw-KE'],
['yor_Latn', 'Yoruba', 'Yorùbá', 'yo-NG'],
['ibo_Latn', 'Igbo', 'Igbo', 'ig-NG'],
['hau_Latn', 'Hausa', 'Hausa', 'ha-NG'],
['lin_Latn', 'Lingala', 'Lingála', 'ln'],
['kin_Latn', 'Kinyarwanda', 'Ikinyarwanda', 'rw-RW'],
['run_Latn', 'Kirundi', 'Ikirundi', 'rn'],
['lug_Latn', 'Luganda', 'Luganda', 'lg'],
['nya_Latn', 'Chichewa', 'Chichewa', 'ny'],
['sna_Latn', 'Shona', 'chiShona', 'sn'],
['zul_Latn', 'Zulu', 'isiZulu', 'zu-ZA'],
['xho_Latn', 'Xhosa', 'isiXhosa', 'xh-ZA'],
['afr_Latn', 'Afrikaans', 'Afrikaans', 'af-ZA'],
['jpn_Jpan', 'Japanese', '日本語', 'ja-JP'],
['ell_Grek', 'Greek', 'Ελληνικά', 'el-GR'],
['hun_Latn', 'Hungarian', 'Magyar', 'hu-HU'],
['ces_Latn', 'Czech', 'Čeština', 'cs-CZ'],
['slk_Latn', 'Slovak', 'Slovenčina', 'sk-SK'],
['bul_Cyrl', 'Bulgarian', 'Български', 'bg-BG'],
['srp_Cyrl', 'Serbian', 'Српски', 'sr-RS'],
['hrv_Latn', 'Croatian', 'Hrvatski', 'hr-HR'],
['bos_Latn', 'Bosnian', 'Bosanski', 'bs-BA'],
['slv_Latn', 'Slovenian', 'Slovenščina', 'sl-SI'],
['als_Latn', 'Albanian', 'Shqip', 'sq-AL'],
['lit_Latn', 'Lithuanian', 'Lietuvių', 'lt-LT'],
['lvs_Latn', 'Latvian', 'Latviešu', 'lv-LV'],
['est_Latn', 'Estonian', 'Eesti', 'et-EE'],
['fin_Latn', 'Finnish', 'Suomi', 'fi-FI'],
['swe_Latn', 'Swedish', 'Svenska', 'sv-SE'],
['nob_Latn', 'Norwegian', 'Norsk', 'nb-NO'],
['dan_Latn', 'Danish', 'Dansk', 'da-DK'],
['isl_Latn', 'Icelandic', 'Íslenska', 'is-IS'],
['kat_Geor', 'Georgian', 'ქართული', 'ka-GE'],
['hye_Armn', 'Armenian', 'Հայերեն', 'hy-AM'],
['azj_Latn', 'Azerbaijani', 'Azərbaycan', 'az-AZ'],
['uzn_Latn', 'Uzbek', 'Oʻzbek', 'uz-UZ'],
['kaz_Cyrl', 'Kazakh', 'Қазақша', 'kk-KZ'],
['kir_Cyrl', 'Kyrgyz', 'Кыргызча', 'ky-KG'],
['tgk_Cyrl', 'Tajik', 'Тоҷикӣ', 'tg-TJ'],
['khk_Cyrl', 'Mongolian', 'Монгол', 'mn-MN'],
['bod_Tibt', 'Tibetan', 'བོད་སྐད་', 'bo'],
['gle_Latn', 'Irish', 'Gaeilge', 'ga-IE'],
['cym_Latn', 'Welsh', 'Cymraeg', 'cy-GB'],
['eus_Latn', 'Basque', 'Euskara', 'eu-ES'],
['cat_Latn', 'Catalan', 'Català', 'ca-ES'],
['glg_Latn', 'Galician', 'Galego', 'gl-ES'],
['quy_Latn', 'Quechua', 'Runasimi', 'qu'],
['ayr_Latn', 'Aymara', 'Aymar aru', 'ay'],
['grn_Latn', 'Guarani', "Avañe'ẽ", 'gn'],
['mri_Latn', 'Maori', 'Te Reo Māori', 'mi-NZ'],
['smo_Latn', 'Samoan', 'Gagana Samoa', 'sm'],
['fij_Latn', 'Fijian', 'Na Vosa Vakaviti', 'fj'],
['tpi_Latn', 'Tok Pisin', 'Tok Pisin', 'tpi']
];
var LANGS = RAW.map(function (r) {
return { code: r[0], name: r[1], native: r[2], tts: r[3], rtl: !!r[4] };
});
var BY_CODE = {};
LANGS.forEach(function (l) { BY_CODE[l.code] = l; });
// A sane opening set for a US church. The operator can change this at any time.
var DEFAULT_FEATURED = [
'spa_Latn', 'zho_Hans', 'por_Latn', 'fra_Latn',
'kor_Hang', 'vie_Latn', 'hat_Latn', 'arb_Arab'
];
function get(code) { return BY_CODE[code] || null; }
function label(code) {
var l = BY_CODE[code];
if (!l) return code;
return l.native === l.name ? l.native : l.native + ' · ' + l.name;
}
/* Case- and accent-insensitive search across both names and the code, so
"espanol", "Español", "spanish" and "spa" all find Spanish. */
function normalize(s) {
return String(s || '')
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '');
}
function search(query) {
var q = normalize(query).trim();
if (!q) return LANGS.slice();
return LANGS.filter(function (l) {
return normalize(l.name).indexOf(q) !== -1 ||
normalize(l.native).indexOf(q) !== -1 ||
normalize(l.code).indexOf(q) !== -1;
});
}
return {
LANGS: LANGS,
DEFAULT_FEATURED: DEFAULT_FEATURED,
get: get,
label: label,
search: search,
codes: function () { return LANGS.map(function (l) { return l.code; }); }
};
}));
+448
View File
@@ -0,0 +1,448 @@
/*
* listen.css — the congregant's screen.
*
* Two rules drive every choice here: it is dark because a bright phone in a
* dim auditorium is rude to the row behind you, and the type is large because
* people are reading it at arm's length in low light while listening.
*/
/*
* Make the `hidden` attribute actually hide things.
*
* The browser's own [hidden] rule is `display: none`, but it lives in the user-
* agent stylesheet, so any author rule that sets `display` — every .warn,
* .toast and flex row below — silently outranks it. The element stays on
* screen with hidden="true" and the JavaScript looks like it isn't running.
* That is how the operator console ended up permanently insisting the Mac
* engine was disconnected while it was sitting there working.
*/
[hidden] { display: none !important; }
:root {
--bg: #0b0d12;
--surface: #151922;
--raised: #1d2331;
--line: #2a3244;
--text: #eef1f6;
--muted: #8b93a7;
--accent: #6ea8fe;
--live: #34d399;
--radius: 16px;
--cap-size: 1.5rem; /* nudged by the Aa button */
--cap-lead: 1.55;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, "Helvetica Neue", Arial, sans-serif;
-webkit-text-size-adjust: 100%;
overscroll-behavior: none;
}
body { overflow: hidden; }
.screen {
position: fixed;
left: 0;
right: 0;
/*
* Pinned to the VISUAL viewport, not the layout viewport.
*
* `position: fixed` is measured against the layout viewport, and on iOS those
* two come apart: collapsing the URL bar pans the visual viewport downward
* while the layout viewport stays put, so a bar fixed at layout-top ends up
* above what the user can actually see. `100dvh` fixed the HEIGHT half of
* that and this is the POSITION half — the same bug wearing a different hat,
* which is why it came back after being fixed once.
*
* The page itself never scrolls (body is overflow:hidden), so the existing
* self-heal — which watches window.scrollY and scrollingElement — sees zero
* on both and correctly does nothing. Nothing was scrolling. The viewport was
* moving underneath.
*
* listen.js keeps these two custom properties in step with visualViewport.
* The fallbacks are what a browser without visualViewport gets, which is the
* behaviour this had before.
*/
top: var(--vv-top, 0px);
height: var(--vv-h, 100dvh);
display: none;
}
.screen.is-active { display: flex; flex-direction: column; }
/* ───────────────────────────── picker ───────────────────────────── */
#picker { overflow-y: auto; -webkit-overflow-scrolling: touch; }
.picker-inner {
width: 100%;
max-width: 40rem;
margin: 0 auto;
padding: max(2.5rem, env(safe-area-inset-top)) 1.25rem calc(2rem + env(safe-area-inset-bottom));
}
.picker-head { text-align: center; margin-bottom: 1.75rem; }
.church {
font-size: .8125rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: .75rem;
}
.picker-head h1 {
font-size: clamp(1.5rem, 6vw, 2rem);
line-height: 1.2;
margin: 0 0 .5rem;
font-weight: 650;
}
/* The same question, cycling through other languages. */
.rotator {
min-height: 1.5rem;
color: var(--muted);
font-size: 1rem;
transition: opacity .4s ease;
}
.rotator.fade { opacity: 0; }
.featured {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
gap: .75rem;
margin-bottom: 1.75rem;
}
.lang-btn {
appearance: none;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
padding: 1.0625rem .875rem;
min-height: 4.25rem;
font: inherit;
text-align: center;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
gap: .1875rem;
transition: background .15s ease, border-color .15s ease, transform .1s ease;
}
.lang-btn:hover { background: var(--raised); border-color: #3a445c; }
.lang-btn:active { transform: scale(.975); }
.lang-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.lang-native { font-size: 1.1875rem; font-weight: 600; line-height: 1.25; }
.lang-en { font-size: .8125rem; color: var(--muted); }
.search-wrap { position: relative; }
#search {
width: 100%;
padding: .9375rem 1rem;
font-size: 1rem;
color: var(--text);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
outline: none;
}
#search:focus { border-color: var(--accent); }
#search::placeholder { color: var(--muted); }
.results { margin-top: .75rem; display: grid; gap: .375rem; }
.results:empty { display: none; }
.result {
appearance: none;
width: 100%;
text-align: start;
border: 1px solid transparent;
background: var(--surface);
color: var(--text);
border-radius: 12px;
padding: .8125rem 1rem;
font: inherit;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
}
.result:hover { background: var(--raised); }
.result:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.result b { font-weight: 600; font-size: 1.0625rem; }
.result span { color: var(--muted); font-size: .8125rem; flex: none; }
.no-results { color: var(--muted); text-align: center; padding: 1rem; font-size: .9375rem; }
/* ───────────────────────────── reader ───────────────────────────── */
.bar {
flex: none;
/* Pinned. The language name and the speaker button are the only controls a
listener has; losing them off the top of a long sermon is not acceptable. */
position: sticky;
top: 0;
z-index: 6;
display: flex;
align-items: center;
gap: .5rem;
padding: max(.5rem, env(safe-area-inset-top)) .625rem .5rem;
background: rgba(11, 13, 18, .92);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
}
.bar-lang {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: .5rem;
font-size: 1rem;
font-weight: 600;
}
#barLangName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dot {
flex: none;
width: .5rem;
height: .5rem;
border-radius: 50%;
background: var(--muted);
transition: background .3s ease;
}
.dot.is-live { background: var(--live); animation: breathe 2.4s ease-in-out infinite; }
@keyframes breathe { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
.bar-actions { display: flex; gap: .25rem; }
.bar-btn {
appearance: none;
border: 0;
background: transparent;
color: var(--text);
width: 2.75rem;
height: 2.75rem;
border-radius: 12px;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.bar-btn:hover { background: var(--raised); }
.bar-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.bar-btn svg {
width: 1.375rem; height: 1.375rem;
fill: none; stroke: currentColor; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round;
}
.bar-btn-text { font-size: 1rem; font-weight: 600; letter-spacing: .01em; }
/* Speaker icon swaps between muted and sounding. */
#btnSpeak .ic-on { display: none; }
#btnSpeak[aria-pressed="true"] { color: var(--accent); }
#btnSpeak[aria-pressed="true"] .ic-on { display: block; }
#btnSpeak[aria-pressed="true"] .ic-off { display: none; }
.stream {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 1.25rem 1.125rem calc(4rem + env(safe-area-inset-bottom));
/* Deliberately NOT scroll-behavior: smooth. That makes every programmatic
scroll an animation, so a page catching up on 40 lines restarts a long
animation on each new sentence and never reaches the bottom. listen.js
picks smooth-or-instant per scroll instead. */
}
.lines { display: flex; flex-direction: column; gap: .875rem; }
.line {
font-size: var(--cap-size);
line-height: var(--cap-lead);
letter-spacing: .003em;
animation: rise .28s ease-out both;
}
@media (prefers-reduced-motion: reduce) { .line { animation: none; } }
@keyframes rise { from { opacity: 0; transform: translateY(.5rem); } to { opacity: 1; transform: none; } }
/*
* The reading eye follows the VOICE, not recency. Every line reads at full
* weight — the newest is not special, because the newest is precisely what the
* voice has NOT said yet — and the one the voice is speaking right now carries
* the highlight, so a listener can read along and always knows where in the
* text the audio is.
*/
.line.is-old { color: #b7bfd2; }
.line.is-speaking {
color: var(--text);
background: rgba(96, 165, 250, .10);
box-shadow: inset 3px 0 0 0 #60a5fa;
border-radius: .375rem;
padding: .125rem .5rem .125rem .625rem;
margin-left: -.625rem;
transition: background .25s ease, box-shadow .25s ease;
}
@media (prefers-reduced-motion: reduce) { .line.is-speaking { transition: none; } }
.line-partial { color: var(--muted); font-style: italic; margin-top: .875rem; }
.line-original {
display: block;
margin-top: .3125rem;
font-size: .8em;
color: var(--muted);
font-style: italic;
}
/* When translation for a language isn't flowing yet. */
.line.is-untranslated { opacity: .72; }
.waiting {
height: 100%;
min-height: 55vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.25rem;
color: var(--muted);
text-align: center;
}
.waiting p { margin: 0; font-size: 1.0625rem; max-width: 20rem; }
.waiting[hidden] { display: none; }
.pulse { display: flex; gap: .4375rem; }
.pulse span {
width: .5rem; height: .5rem; border-radius: 50%;
background: var(--muted);
animation: bounce 1.4s ease-in-out infinite;
}
.pulse span:nth-child(2) { animation-delay: .18s; }
.pulse span:nth-child(3) { animation-delay: .36s; }
@keyframes bounce { 0%, 60%, 100% { transform: translateY(0); opacity: .4; } 30% { transform: translateY(-.4375rem); opacity: 1; } }
.jump {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(1.25rem + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: .375rem;
padding: .625rem 1.125rem;
border: 0;
border-radius: 999px;
background: var(--accent);
color: #08101f;
font: inherit;
font-weight: 650;
font-size: .9375rem;
cursor: pointer;
box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .5);
}
.jump[hidden] { display: none; }
.jump svg { width: 1.125rem; height: 1.125rem; fill: none; stroke: currentColor; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.toast {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(4.5rem + env(safe-area-inset-bottom));
background: var(--raised);
border: 1px solid var(--line);
color: var(--text);
padding: .625rem 1rem;
border-radius: 999px;
font-size: .875rem;
max-width: calc(100vw - 2.5rem);
text-align: center;
z-index: 10;
}
.toast[hidden] { display: none; }
/* Text-size steps, driven by the Aa button. */
body.size-2 { --cap-size: 1.875rem; }
body.size-3 { --cap-size: 2.375rem; --cap-lead: 1.45; }
/* Right-to-left scripts. */
#reader[dir="rtl"] .stream,
#reader[dir="rtl"] .lines { text-align: right; }
/* The "EN" button: tap it to see the preacher's own words under each line. */
.bar-btn-src { font-size: .8125rem; letter-spacing: .04em; color: var(--muted); }
.bar-btn-src[aria-pressed="true"] { color: var(--accent); background: var(--raised); }
/* ---- voice picker: only appears when speech is on and there is a choice ---- */
.voicebar {
flex: none;
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
gap: .625rem;
padding: .5rem 1.125rem .5625rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.voicebar label {
font-size: .6875rem;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--muted);
flex: none;
}
.voicebar select {
flex: 1;
min-width: 0;
appearance: none;
background: var(--raised);
color: var(--text);
border: 1px solid var(--line);
border-radius: 9px;
padding: .4375rem .75rem;
font: inherit;
font-size: .875rem;
}
.voicebar select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Voices come from the device, not from us. When it has few or poor ones, say
where better ones live rather than letting people conclude we sound bad. */
.voicehint {
flex: none;
margin: 0;
padding: .5rem 1.125rem .625rem;
font-size: .78125rem;
line-height: 1.45;
color: var(--muted);
background: var(--surface);
border-bottom: 1px solid var(--line);
}
.voicehint[hidden] { display: none; }
.voicehint b { color: var(--text); font-weight: 600; }
/* iOS renders no native clear control for type=search — this is it. Centered
on the input (not .search-wrap, which grows when results render). */
.search-box { position: relative; }
#search { padding-inline-end: 3.25rem; font-size: max(1rem, 16px); }
.search-clear {
position: absolute; top: 50%; right: .5rem; transform: translateY(-50%);
width: 2.25rem; height: 2.25rem; border: 0; border-radius: 50%;
background: rgba(148,163,184,.18); color: #cbd5e6; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.search-clear svg { width: .875rem; height: .875rem; stroke: currentColor; stroke-width: 2.5; fill: none; }
.search-clear:active { background: rgba(148,163,184,.32); }
File diff suppressed because one or more lines are too long
+201
View File
@@ -0,0 +1,201 @@
/*
* operator.css — the sound desk view.
*
* The START button is deliberately enormous. On a Sunday it is pressed once,
* often by a volunteer, sometimes in the dark, sometimes on a tablet, usually
* while three other things are going wrong.
*/
/*
* Make the `hidden` attribute actually hide things.
*
* The browser's own [hidden] rule is `display: none`, but it lives in the user-
* agent stylesheet, so any author rule that sets `display` — every .warn,
* .toast and flex row below — silently outranks it. The element stays on
* screen with hidden="true" and the JavaScript looks like it isn't running.
* That is how the operator console ended up permanently insisting the Mac
* engine was disconnected while it was sitting there working.
*/
[hidden] { display: none !important; }
:root {
--bg: #0b0d12; --surface: #151922; --raised: #1d2331; --line: #2a3244;
--text: #eef1f6; --muted: #8b93a7; --accent: #6ea8fe;
--go: #2fbf71; --stop: #e5484d; --warn: #f5a524;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-webkit-text-size-adjust: 100%;
}
h1 { font-size: 1.5rem; margin: 0; font-weight: 650; }
h2 { font-size: 1.0625rem; margin: 0 0 .375rem; font-weight: 650; }
h2.mt { margin-top: 1.75rem; }
p { margin: 0 0 .875rem; line-height: 1.5; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8125rem; }
.muted { color: var(--muted); font-size: .875rem; }
.tiny { font-size: .8125rem; }
.err { color: var(--stop); font-size: .875rem; }
.eyebrow { font-size: .75rem; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin-bottom: .25rem; }
/* ---- PIN gate ---- */
.gate { display: none; min-height: 100dvh; place-items: center; padding: 1.25rem; }
.gate.is-active { display: grid; }
#console { display: none; }
#console.is-active { display: block; }
.gate-card {
width: 100%; max-width: 21rem;
background: var(--surface); border: 1px solid var(--line);
border-radius: 18px; padding: 1.75rem;
display: flex; flex-direction: column; gap: .75rem;
}
/* ---- shell ---- */
.wrap { max-width: 46rem; margin: 0 auto; padding: 1.5rem 1.125rem 5rem; }
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1.25rem; }
.ghost { color: var(--accent); text-decoration: none; font-size: .875rem; white-space: nowrap; }
.ghost:hover { text-decoration: underline; }
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: 18px; padding: 1.375rem; margin-bottom: 1rem;
}
.card.control { padding-bottom: 1rem; }
/* ---- the button ---- */
.big {
width: 100%; min-height: 8.5rem;
border: 0; border-radius: 16px; cursor: pointer;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .375rem;
background: var(--go); color: #052e18;
font: inherit; transition: background .18s ease, transform .08s ease;
}
.big:active { transform: scale(.99); }
.big:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
.big[data-live="true"] { background: var(--stop); color: #fff; }
.big[disabled] { background: var(--raised); color: var(--muted); cursor: not-allowed; }
.big-label { font-size: 1.625rem; font-weight: 750; letter-spacing: .02em; }
.big-sub { font-size: .9375rem; opacity: .85; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; margin-top: 1rem; }
.stat { background: var(--raised); border-radius: 12px; padding: .75rem; text-align: center; }
.stat b { display: block; font-size: 1.375rem; font-weight: 700; }
.stat span { font-size: .75rem; color: var(--muted); }
.warn {
margin-top: 1rem; padding: .875rem 1rem;
background: rgba(245, 165, 36, .12);
border: 1px solid rgba(245, 165, 36, .4);
border-radius: 12px; font-size: .875rem; line-height: 1.5;
display: flex; flex-direction: column; gap: .25rem;
}
.warn strong { color: var(--warn); }
.enginebar {
margin-top: .75rem; font-size: .8125rem; color: var(--muted);
display: flex; flex-wrap: wrap; gap: .875rem;
}
/* ---- inputs ---- */
input, textarea, select {
width: 100%; padding: .75rem .875rem;
background: var(--raised); color: var(--text);
border: 1px solid var(--line); border-radius: 10px;
font: inherit; font-size: .9375rem; outline: none;
}
input:focus, textarea:focus { border-color: var(--accent); }
textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8125rem; line-height: 1.6; }
label { display: block; font-size: .875rem; color: var(--muted); margin-bottom: .5rem; }
label input { margin-top: .375rem; }
.btn {
appearance: none; border: 1px solid var(--line);
background: var(--raised); color: var(--text);
padding: .6875rem 1.125rem; border-radius: 10px;
font: inherit; font-size: .9375rem; font-weight: 550;
cursor: pointer; text-decoration: none; display: inline-block; text-align: center;
}
.btn:hover { background: #262d3d; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #08101f; font-weight: 650; }
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-top: .75rem; }
.row .btn { flex: none; }
.row input { flex: 1; min-width: 10rem; }
/* ---- language chips ---- */
.chips { display: flex; flex-wrap: wrap; gap: .4375rem; margin-bottom: .75rem; min-height: 2rem; }
.chip {
display: inline-flex; align-items: center; gap: .4375rem;
background: var(--raised); border: 1px solid var(--line);
border-radius: 999px; padding: .375rem .5rem .375rem .8125rem;
font-size: .875rem;
}
.chip button {
appearance: none; border: 0; background: transparent; color: var(--muted);
cursor: pointer; font-size: 1.125rem; line-height: 1; padding: 0 .25rem;
}
.chip button:hover { color: var(--stop); }
.chips:empty::before { content: "None yet"; color: var(--muted); font-size: .875rem; }
.results { display: grid; gap: .25rem; margin-top: .5rem; max-height: 15rem; overflow-y: auto; }
.results:empty { display: none; }
.results button {
appearance: none; text-align: left; width: 100%;
background: var(--raised); border: 1px solid transparent; color: var(--text);
border-radius: 8px; padding: .5625rem .75rem; font: inherit; font-size: .875rem; cursor: pointer;
}
.results button:hover { border-color: var(--accent); }
.results button span { color: var(--muted); margin-left: .5rem; font-size: .8125rem; }
/* ---- qr + code ---- */
.qrrow { display: flex; gap: 1.25rem; flex-wrap: wrap; align-items: flex-start; }
.qr { width: 10rem; height: 10rem; background: #fff; border-radius: 12px; padding: .5rem; flex: none; }
.qrside { flex: 1; min-width: 14rem; }
.codeblock {
display: flex; align-items: center; gap: .5rem;
background: var(--raised); border: 1px solid var(--line);
border-radius: 10px; padding: .625rem .75rem; margin-bottom: .5rem;
}
.codeblock code { flex: 1; overflow-x: auto; white-space: nowrap; color: #cbd5e6; }
.copy {
appearance: none; border: 0; background: var(--line); color: var(--text);
border-radius: 7px; padding: .3125rem .625rem; font: inherit; font-size: .75rem; cursor: pointer; flex: none;
}
.copy:hover { background: var(--accent); color: #08101f; }
.toast {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: 1.5rem; background: var(--raised); border: 1px solid var(--line);
padding: .6875rem 1.125rem; border-radius: 999px; font-size: .875rem; z-index: 20;
}
.toast[hidden] { display: none; }
@media (max-width: 30rem) {
.big-label { font-size: 1.375rem; }
.qr { width: 100%; height: auto; max-width: 15rem; margin: 0 auto; }
}
/* ---- who is listening, by language ---- */
.wholist { display: flex; flex-wrap: wrap; gap: .4375rem; margin-top: .75rem; }
.wholist[hidden] { display: none; }
.who {
display: inline-flex; align-items: baseline; gap: .4375rem;
background: var(--raised); border: 1px solid var(--line);
border-radius: 999px; padding: .3125rem .75rem;
font-size: .8125rem;
}
.who b { font-variant-numeric: tabular-nums; font-weight: 700; font-size: .9375rem; }
.who span { color: var(--muted); }
.op-health { margin: .5rem 0 0; font-size: .8125rem; font-weight: 600; text-align: center; }
.op-health[data-level="green"] { color: #4ade80; }
.op-health[data-level="amber"] { color: #fbbf24; }
.op-health[data-level="red"] { color: #f87171; }
+129
View File
@@ -0,0 +1,129 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b0d12">
<meta name="color-scheme" content="dark">
<title>Translation — Operator</title>
<link rel="stylesheet" href="/operator.css">
</head>
<body>
<!-- PIN gate ------------------------------------------------------------- -->
<section id="gate" class="gate is-active">
<form id="gateForm" class="gate-card">
<h1>Operator</h1>
<p class="muted">Enter the PIN for this church's translation system.</p>
<input id="pin" type="password" inputmode="numeric" autocomplete="current-password"
placeholder="PIN" aria-label="Operator PIN" required>
<button type="submit" class="btn btn-primary">Unlock</button>
<p id="gateErr" class="err" hidden></p>
<p class="muted tiny">The PIN is in <code>data/OPERATOR-PIN.txt</code> on the NAS.</p>
</form>
</section>
<!-- Console -------------------------------------------------------------- -->
<main id="console" class="wrap">
<header class="top">
<div>
<div class="eyebrow">Live Translation</div>
<h1 id="svcName">—</h1>
</div>
<a class="ghost" href="/" target="_blank" rel="noopener">Listener view ↗</a>
</header>
<!-- The one control that matters on a Sunday morning. -->
<section class="card control">
<button id="bigBtn" class="big" data-live="false">
<span class="big-label">START TRANSLATING</span>
<span class="big-sub" id="bigSub">Ready</span>
</button>
<p class="op-health" id="opHealth" hidden></p>
<div class="stats">
<div class="stat"><b id="stListeners">0</b><span>listening</span></div>
<div class="stat"><b id="stLangs">0</b><span>languages</span></div>
<div class="stat"><b id="stElapsed">—</b><span>elapsed</span></div>
</div>
<!-- Who is actually reading, and in what. The total alone does not tell you
whether a language is worth keeping. -->
<div id="whoList" class="wholist" hidden></div>
<div id="engineWarn" class="warn" hidden>
<strong>The Mac engine is not connected.</strong>
<span>Open <em>Church Translate Engine</em> on the backstage Mac Studio. Nothing will translate until it is running.</span>
</div>
<div id="engineInfo" class="enginebar" hidden></div>
</section>
<!-- Languages ---------------------------------------------------------- -->
<section class="card">
<h2>Languages on the front page</h2>
<p class="muted">These show as big buttons before anyone searches. Everything else
is still reachable through search — all <b id="totalLangs">0</b> of them.</p>
<div id="featuredList" class="chips"></div>
<input id="langSearch" type="search" placeholder="Add a language…" autocomplete="off">
<div id="langResults" class="results"></div>
<h2 class="mt">Always translate</h2>
<p class="muted">Normally a language is only translated while someone is actually
listening to it. Pin one here to have it ready before the first person arrives.</p>
<div id="pinnedList" class="chips"></div>
</section>
<!-- Glossary ----------------------------------------------------------- -->
<section class="card">
<h2>Corrections</h2>
<p class="muted">One per line, in the form
<code>what it heard =&gt; what it should say</code>. This is how “have a cook”
becomes “Habakkuk” and your pastor’s name stops being a different word every
week. Matching ignores capitals and only hits whole words.</p>
<textarea id="bias" rows="7" spellcheck="false"></textarea>
<button id="saveGlossary" class="btn">Save corrections</button>
</section>
<!-- Setup -------------------------------------------------------------- -->
<section class="card">
<h2>The QR code</h2>
<div class="qrrow">
<img id="qr" class="qr" alt="QR code to the listener page">
<div class="qrside">
<label>Public address
<input id="publicUrl" type="url" placeholder="https://translate.yourchurch.org" spellcheck="false">
</label>
<p class="muted tiny">Leave blank to use whatever address this page was opened at.
Set it once the Cloudflare tunnel is up so the QR code always points at the public name.</p>
<div class="row">
<button id="savePublic" class="btn">Save</button>
<a id="posterLink" class="btn" href="/poster" target="_blank" rel="noopener">Printable poster ↗</a>
</div>
</div>
</div>
</section>
<section class="card">
<h2>Stream Deck / Companion</h2>
<p class="muted">Add a <b>Generic HTTP</b> action in Companion. Method GET, and one of:</p>
<div class="codeblock"><code id="urlToggle">—</code><button class="copy" data-copy="urlToggle">Copy</button></div>
<div class="codeblock"><code id="urlStart">—</code><button class="copy" data-copy="urlStart">Copy</button></div>
<div class="codeblock"><code id="urlStop">—</code><button class="copy" data-copy="urlStop">Copy</button></div>
<p class="muted tiny">Keep this key off the projector — anyone with it can start and stop translation.</p>
</section>
<section class="card">
<h2>Change the PIN</h2>
<div class="row">
<input id="newPin" type="password" placeholder="New PIN (4+ characters)" autocomplete="new-password">
<button id="savePin" class="btn">Update PIN</button>
</div>
</section>
<div id="toast" class="toast" hidden></div>
</main>
<script src="/languages.js"></script>
<script src="/operator.js"></script>
</body>
</html>
+333
View File
@@ -0,0 +1,333 @@
/*
* 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();
}
})();
+94
View File
@@ -0,0 +1,94 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Translation — Poster</title>
<style>
:root { --ink: #10131a; --muted: #5b6478; }
* { box-sizing: border-box; }
body {
margin: 0; background: #f4f5f8; color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
display: grid; place-items: center; min-height: 100vh; padding: 1.5rem;
}
.sheet {
width: 100%; max-width: 46rem; aspect-ratio: 8.5 / 11;
background: #fff; border-radius: 12px; box-shadow: 0 1.5rem 3rem rgba(0,0,0,.12);
padding: 3.5rem 3rem; display: flex; flex-direction: column; align-items: center;
text-align: center; gap: 1.25rem;
}
.church { font-size: .875rem; letter-spacing: .18em; text-transform: uppercase; color: var(--muted); }
h1 { font-size: 2.75rem; line-height: 1.1; margin: 0; font-weight: 750; letter-spacing: -.01em; }
.sub { font-size: 1.25rem; color: var(--muted); margin: 0; }
.qr { width: 17rem; height: 17rem; margin: .5rem 0; }
.url { font-size: 1.125rem; font-weight: 600; letter-spacing: .01em; word-break: break-all; }
.langs {
display: flex; flex-wrap: wrap; justify-content: center; gap: .5rem .875rem;
font-size: 1.0625rem; color: var(--ink); margin-top: auto; padding-top: 1.25rem;
border-top: 1px solid #e3e6ec; width: 100%;
}
.langs span { white-space: nowrap; }
.foot { font-size: .8125rem; color: var(--muted); }
.bar { max-width: 46rem; width: 100%; display: flex; gap: .5rem; justify-content: flex-end; margin-bottom: .75rem; }
.bar button {
border: 1px solid #d3d8e2; background: #fff; color: var(--ink);
padding: .5rem 1rem; border-radius: 8px; font: inherit; font-size: .875rem; cursor: pointer;
}
@media print {
body { background: #fff; padding: 0; display: block; }
.bar { display: none; }
.sheet { box-shadow: none; border-radius: 0; max-width: none; width: 100%; aspect-ratio: auto; min-height: 100vh; }
}
</style>
</head>
<body>
<div class="bar"><button onclick="window.print()">Print</button></div>
<div class="sheet">
<div class="church" id="church">—</div>
<h1 id="head">Listen in your language</h1>
<p class="sub" id="sub">Scan with your phone camera</p>
<img class="qr" id="qr" alt="QR code">
<div class="url" id="url">—</div>
<div class="langs" id="langs"></div>
<div class="foot">Free · No app to download · Works on any phone</div>
</div>
<script src="/languages.js"></script>
<script>
(function () {
var params = new URLSearchParams(location.search);
var pin = params.get('pin');
/* "Listen in your language" written in the languages most likely to be in the
room — a poster nobody can read is just decoration. */
var INVITES = ['Escucha en tu idioma', '用您的语言收听', 'Écoutez dans votre langue',
'Ouça no seu idioma', '당신의 언어로 들으세요', 'Nghe bằng ngôn ngữ của bạn',
'Koute nan lang ou', 'استمع بلغتك', 'Слухайте своєю мовою', 'Sikiliza kwa lugha yako'];
function render(cfg, base) {
document.getElementById('church').textContent = cfg.serviceName || '';
document.getElementById('url').textContent = base.replace(/^https?:\/\//, '');
document.getElementById('qr').src = '/qr.svg?url=' + encodeURIComponent(base + '/');
var box = document.getElementById('langs');
box.textContent = '';
INVITES.forEach(function (t) {
var s = document.createElement('span');
s.textContent = t;
box.appendChild(s);
});
}
var url = pin ? '/api/config' : '/api/languages';
var opts = pin ? { headers: { 'X-Operator-Pin': pin } } : {};
fetch(url, opts).then(function (r) { return r.json(); }).then(function (j) {
var cfg = j.config || j;
var base = (cfg.publicUrl || j.publicBase || location.origin).replace(/\/+$/, '');
render(cfg, base);
}).catch(function () {
render({ serviceName: '' }, location.origin);
});
})();
</script>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
/*
* ui-strings.js — the handful of words on the listener page itself.
*
* Someone who needs a Spanish sermon should not have to read an English button
* to get it. These are hand-written for the languages a US church sees most;
* anything else falls back to English, and the picker still shows every
* language in its own script.
*
* Order: choose, waiting, speak, stop, reconnecting, change, original, live
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.UIStrings = factory();
}(typeof self !== 'undefined' ? self : this, function () {
var KEYS = ['choose', 'waiting', 'speak', 'stop', 'reconnecting', 'change', 'original', 'live'];
var TABLE = {
eng_Latn: ['Choose your language', "The message hasn't started yet", 'Speak aloud', 'Stop', 'Reconnecting…', 'Change language', 'Show original', 'Live'],
spa_Latn: ['Elige tu idioma', 'El mensaje aún no ha comenzado', 'Leer en voz alta', 'Detener', 'Reconectando…', 'Cambiar idioma', 'Ver original', 'En vivo'],
zho_Hans: ['选择您的语言', '信息尚未开始', '朗读', '停止', '正在重新连接…', '更换语言', '显示原文', '直播中'],
zho_Hant: ['選擇您的語言', '信息尚未開始', '朗讀', '停止', '正在重新連線…', '更換語言', '顯示原文', '直播中'],
yue_Hant: ['揀你嘅語言', '信息仲未開始', '朗讀', '停止', '重新連線緊…', '轉語言', '顯示原文', '直播中'],
fra_Latn: ['Choisissez votre langue', "Le message n'a pas encore commencé", 'Lire à voix haute', 'Arrêter', 'Reconnexion…', 'Changer de langue', "Voir l'original", 'En direct'],
por_Latn: ['Escolha o seu idioma', 'A mensagem ainda não começou', 'Ler em voz alta', 'Parar', 'Reconectando…', 'Mudar idioma', 'Ver original', 'Ao vivo'],
por_PT: ['Escolha o seu idioma', 'A mensagem ainda não começou', 'Ler em voz alta', 'Parar', 'A restabelecer ligação…', 'Mudar idioma', 'Ver original', 'Em direto'],
kor_Hang: ['언어를 선택하세요', '메시지가 아직 시작되지 않았습니다', '소리내어 읽기', '중지', '다시 연결 중…', '언어 변경', '원문 보기', '실시간'],
vie_Latn: ['Chọn ngôn ngữ của bạn', 'Bài giảng chưa bắt đầu', 'Đọc to', 'Dừng', 'Đang kết nối lại…', 'Đổi ngôn ngữ', 'Xem bản gốc', 'Trực tiếp'],
hat_Latn: ['Chwazi lang ou', 'Mesaj la poko kòmanse', 'Li awotvwa', 'Sispann', 'N ap rekonekte…', 'Chanje lang', 'Montre orijinal la', 'An dirèk'],
arb_Arab: ['اختر لغتك', 'لم تبدأ الرسالة بعد', 'القراءة بصوت عالٍ', 'إيقاف', 'جارٍ إعادة الاتصال…', 'تغيير اللغة', 'عرض النص الأصلي', 'مباشر'],
rus_Cyrl: ['Выберите язык', 'Проповедь ещё не началась', 'Читать вслух', 'Стоп', 'Переподключение…', 'Сменить язык', 'Показать оригинал', 'В эфире'],
ukr_Cyrl: ['Оберіть мову', 'Проповідь ще не почалася', 'Читати вголос', 'Стоп', 'Перепідключення…', 'Змінити мову', 'Показати оригінал', 'Наживо'],
deu_Latn: ['Wähle deine Sprache', 'Die Predigt hat noch nicht begonnen', 'Vorlesen', 'Stopp', 'Neu verbinden…', 'Sprache ändern', 'Original anzeigen', 'Live'],
ita_Latn: ['Scegli la tua lingua', 'Il messaggio non è ancora iniziato', 'Leggi ad alta voce', 'Ferma', 'Riconnessione…', 'Cambia lingua', 'Mostra originale', 'In diretta'],
tgl_Latn: ['Piliin ang iyong wika', 'Hindi pa nagsisimula ang mensahe', 'Basahin nang malakas', 'Itigil', 'Kumokonekta muli…', 'Palitan ang wika', 'Ipakita ang orihinal', 'Live'],
swh_Latn: ['Chagua lugha yako', 'Ujumbe bado haujaanza', 'Soma kwa sauti', 'Simamisha', 'Inaunganisha tena…', 'Badilisha lugha', 'Onyesha asili', 'Moja kwa moja'],
amh_Ethi: ['ቋንቋዎን ይምረጡ', 'መልእክቱ ገና አልጀመረም', 'ጮክ ብሎ ማንበብ', 'አቁም', 'እንደገና በመገናኘት ላይ…', 'ቋንቋ ቀይር', 'ዋናውን አሳይ', 'በቀጥታ'],
pes_Arab: ['زبان خود را انتخاب کنید', 'پیام هنوز شروع نشده است', 'خواندن با صدای بلند', 'توقف', 'در حال اتصال مجدد…', 'تغییر زبان', 'نمایش متن اصلی', 'زنده'],
hin_Deva: ['अपनी भाषा चुनें', 'संदेश अभी शुरू नहीं हुआ है', 'ज़ोर से पढ़ें', 'रोकें', 'पुनः कनेक्ट हो रहा है…', 'भाषा बदलें', 'मूल दिखाएँ', 'लाइव'],
jpn_Jpan: ['言語を選択してください', 'メッセージはまだ始まっていません', '読み上げる', '停止', '再接続中…', '言語を変更', '原文を表示', 'ライブ'],
pol_Latn: ['Wybierz swój język', 'Kazanie jeszcze się nie zaczęło', 'Czytaj na głos', 'Zatrzymaj', 'Ponowne łączenie…', 'Zmień język', 'Pokaż oryginał', 'Na żywo'],
ron_Latn: ['Alege limba ta', 'Mesajul nu a început încă', 'Citește cu voce tare', 'Oprește', 'Reconectare…', 'Schimbă limba', 'Arată originalul', 'În direct'],
som_Latn: ['Dooro luqaddaada', 'Farriintu weli ma bilaaban', 'Kor u akhri', 'Jooji', 'Dib u xidhiidhinaya…', 'Beddel luqadda', 'Tus qoraalka asalka ah', 'Toos'],
npi_Deva: ['आफ्नो भाषा छान्नुहोस्', 'सन्देश अझै सुरु भएको छैन', 'ठूलो स्वरमा पढ्नुहोस्', 'रोक्नुहोस्', 'पुन: जडान हुँदै…', 'भाषा परिवर्तन गर्नुहोस्', 'मूल देखाउनुहोस्', 'प्रत्यक्ष'],
mya_Mymr: ['သင့်ဘာသာစကားကို ရွေးပါ', 'တရားဒေသနာ မစသေးပါ', 'အသံထွက်ဖတ်ပါ', 'ရပ်ပါ', 'ပြန်လည်ချိတ်ဆက်နေသည်…', 'ဘာသာစကားပြောင်းပါ', 'မူရင်းပြပါ', 'တိုက်ရိုက်']
};
function pack(arr) {
var o = {};
KEYS.forEach(function (k, i) { o[k] = arr[i]; });
return o;
}
var STRINGS = {};
Object.keys(TABLE).forEach(function (code) { STRINGS[code] = pack(TABLE[code]); });
function forLang(code) {
/* Fall back to English rather than showing a key name. A missing string is
a cosmetic problem; a blank button is a broken page. */
/* A regional variant reads its base language's strings; showing a Spanish
speaker an English page because the code says spa_ES rather than
spa_Latn is exactly what languages.js says never to do. */
var VARIANT_OF = { por_PT: 'por_Latn', spa_ES: 'spa_Latn', spa_MX: 'spa_Latn' };
return Object.assign({}, STRINGS.eng_Latn, STRINGS[code] || STRINGS[VARIANT_OF[code]] || {});
}
return { KEYS: KEYS, STRINGS: STRINGS, forLang: forLang, has: function (c) { return !!STRINGS[c]; } };
}));