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:
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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; }); }
|
||||
};
|
||||
}));
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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 => 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>
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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]; } };
|
||||
}));
|
||||
Reference in New Issue
Block a user