22 changed files with 520 additions and 68 deletions

No files matched your search

+3 -1
View File
@@ -62,9 +62,11 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Book store** (Books -> Book store): search free, DRM-free books from **Project Gutenberg** (its own catalogue feed), **Standard Ebooks** (its public website: its OPDS feed is for paying members only, so the hub reads the public search pages and downloads the free EPUB), the **Internet Archive** (public-domain EPUBs only; borrow-only items are refused) and any **OPDS catalogue** an admin adds under *Catalogues* (Calibre-Web, Kavita, Komga and so on; an optional username and password are kept encrypted on the server and never sent to the browser). One search covers every source, results show their cover and source, and *Add to library* downloads the book on the server straight onto your shelf. The server only ever contacts a source's own addresses (redirects to other sites are refused), covers come through the hub, and a catalogue book must live on that catalogue's own address. Settings: `GUTENBERG_URL`, `STANDARD_EBOOKS_URL` and `ARCHIVE_URL` can point at mirrors. `node tools/store-test.js` tests it against fake catalogues; it was also tried against the live Project Gutenberg, Standard Ebooks and Internet Archive (searches, and real EPUBs downloaded); if one of them changes its pages or feed, the line under the search box names the source that failed.
- The reader scripts are in `public/vendor/reader/` (epub.js, JSZip, pdf.js, mammoth, the MOBI parser) and load only when a book is opened or added. `node tools/books-test.js` tests the server; `python3 tools/mkbooks.py <dir>` makes a small EPUB, PDF and MOBI to try. **DRM-protected books are refused** (a Kindle file with copy protection is detected on upload and the reason is shown); only DRM-free books work. The MOBI reader was tried on a generated MOBI 6 file, not on real Kindle files, so tell me about any book that opens oddly. Not included: text-to-speech, offline reading.
**Free audiobooks** (Audiobooks -> Free audiobooks; works without any audiobook sign-in): search **LibriVox**, thousands of public-domain books read aloud by volunteers, by title or author. A book shows its length, language, description and chapters; *Play* (or *Resume*) queues every chapter in the same player as Music so the next one follows by itself, and starting a book puts it on your shelf (*My free audiobooks*, with a progress bar; *Remove from shelf* in the book's sheet). Where you got to is kept on the server per person, so it follows you between devices. The hub searches LibriVox and hands the browser each chapter's address, but only addresses on LibriVox or the Internet Archive, which hosts the recordings; covers come through the hub. `LIBRIVOX_URL` points at a mirror. `node tools/librivox-test.js` tests it against a fake LibriVox; it has not been run against the live site from the build environment.
- **Radio**: internet radio, with **The Journey FM** (Liberty University, Lynchburg, Virginia; MP3 192 kbps, `http://ic.liberty.edu:8000/WRVL`) always on top, showing what is playing now and the last songs played from the station's own feed. Below it, search thousands of stations from the community [Radio Browser](https://www.radio-browser.info/) directory (free, no key) by name or with quick topic chips (Christian, Worship, Gospel, Talk, News, Classical, Jazz...). The heart saves a station to *My stations* (your own list). Stations play in the same player as Music, so they keep playing as you move between tabs. The hub fetches each stream itself and passes it on, because many stations only offer plain http, which a browser refuses to play inside an https page; it also reads the song title carried in the stream (the player shows it) and fetches station logos. The hub never connects to private, local or link-local addresses for a station. Give it to people in Settings -> People (Radio). `JOURNEYFM_STREAM_URL`, `JOURNEYFM_NOW_URL` and `RADIO_BROWSER_URL` can override the addresses. `node tools/radio-test.js` tests it against fake stations; the real Journey stream could not be reached from the build environment, so if it will not play, open the station's own *Website* button and tell me what changed. Not included: HLS-only stations, recording, alarms.
- **Radio**: internet radio, with **The Journey FM** (Liberty University, Lynchburg, Virginia; MP3 192 kbps, `http://ic.liberty.edu:8000/WRVL`) always on top, showing what is playing now and the last songs played from the station's own feed. Below it, search thousands of stations from the community [Radio Browser](https://www.radio-browser.info/) directory (free, no key) by name or with quick topic chips (Christian, Worship, Gospel, Talk, News, Classical, Jazz...). The heart saves a station to *My stations* (your own list). Stations play in the same player as Music, so they keep playing as you move between tabs. The hub fetches each stream itself and passes it on, because many stations only offer plain http, which a browser refuses to play inside an https page; it also reads the song title carried in the stream (the player shows it) and fetches station logos. The hub never connects to private, local or link-local addresses for a station. Give it to people in Settings -> People (Radio). `JOURNEYFM_STREAM_URL`, `JOURNEYFM_NOW_URL` and `RADIO_BROWSER_URL` can override the addresses. Lyrics come from the Lyrica service if you run one (`LYRICS_API_URL`), and otherwise (or when Lyrica finds nothing) from [LRCLIB](https://lrclib.net), a free lyrics database that needs no key: the artist and song title are sent to it, and set `LYRICS_LRCLIB=0` to never ask it. `node tools/radio-test.js` and `node tools/lrclib-test.js` test it against fakes; the real Journey stream could not be reached from the build environment, so if it will not play, open the station's own *Website* button and tell me what changed. **Song and artist**: the player shows the song as the title with the artist under it (and the station as the album), also on the mini player, the Now Playing sheet, the Radio page and the phone's lock screen; it follows the station every 10 seconds. The Journey FM's own feed gives the artist and when each song began; other stations send `Artist - Title` inside the stream, which the hub splits. **Lyrics** (the Lyrics button on the player or the Radio page) are looked up for the song that is on now and change when the song does. A station that says when a song began (The Journey FM) gets synced lyrics that follow along; because what you hear is a few seconds behind the station's clock, it starts 5 seconds early and the - and + buttons move it a second at a time (remembered for that station). Other stations show the words without following along. Not included: HLS-only stations, recording, alarms.
- **Verse By Verse** (Podcast -> *Verse By Verse*; needs only the Podcast tab, not Podgrab): free verse-by-verse Bible teaching from [Verse By Verse Ministry International](https://versebyverseministry.org). Browse the ministry's studies (about 80: Old and New Testament books, topical series, single teachings, women's and youth), search them, and open a study to see its lessons with dates and summaries. *Start* / *Continue* plays the study lesson after lesson in the same player as Music, a lesson resumes where you left off (kept on the server per person, so it follows you between devices), and each lesson's handout (PDF) opens from *Notes*. The hub reads the study list from the ministry's category pages and the lessons from each study's own podcast feed (`/bible-studies/<study>/podcast.xml`), keeps what it read for 30 to 60 minutes so the ministry's site is not asked on every visit, and sends the browser straight to the ministry's media server for the audio. Only the ministry's own addresses are ever used. `VBV_URL` can point at a mirror. `node tools/vbv-test.js` tests it against a fake copy of the site; it was also tried against the live site (80 studies, a study's lessons, audio and handout links). If the ministry redesigns its pages, the study list is the first thing that would break, and the screen would say so.
- **Save to Files**: copy things into your own Seafile from where you find them. **Books** (long-press or right-click a book -> *Save to Files*), **free audiobooks** (a book's sheet -> *Save to Files*, one numbered MP3 per chapter in a folder) and **Verse By Verse** (a study's *Save to Files* button saves every lesson and handout, or just the audio or just the handouts; the download button on a lesson saves that one). You pick the library and folder (it remembers the last one); the hub makes a tidy subfolder (`Books`, `Audiobooks/<title>`, `Verse By Verse/<study>`), names files safely and in order, **skips anything already there** (so running it again fills in what is missing) and counts lessons that have no handout instead of failing. The hub does the downloading and uploading itself with the Seafile sign-in already saved for you, so a whole study (several GB) does not pass through your phone and keeps going if you tap *Hide*; a sheet shows progress, *Stop* cancels, and *Open folder* jumps to the result. It needs the Files tab and the tab the content comes from (Books, Audiobooks or Podcast); each person can have two saves running at once; files up to 500 MB. `node tools/savefiles-test.js` tests it against a fake Seafile.
- **Bible**: the **Berean Standard Bible** (BSB, public domain: https://berean.bible/), all 66 books, with its section headings, poetry layout and footnotes. The whole text is one file (`public/vendor/bible/bsb.json`, about 1.5 MB compressed, made by `python3 tools/make-bsb.py` from the free Hello AO Bible API) that the browser loads once and keeps, so reading is instant and works offline. **Choose** a book and chapter, **search** for words (every word must be in the verse) or type a place (`John 3:16`, `1 cor 13:4-7`, `ps 23`), swipe or use the arrow keys to turn chapters, and tap verses to **highlight** (four colours), add a **note**, or **copy** them with their reference. Tap a small letter for a footnote. *My highlights and notes* lists everything. Your place, highlights and notes are kept on the server per person, so they follow you between devices; text size and font are remembered per device. Give it to people in Settings -> People (Bible). `node tools/bible-test.js` tests the saving and checks all 31,086 verses are in the file.
- **Menu on a phone**: related tabs share one entry (**Listen**: Music, Radio, Podcast, Audiobooks; **Read**: Bible, Books; each part still needs its own permission), with a row of buttons under the top bar to switch between the parts (it remembers the last one you used). The **bottom bar** is yours to choose: *More* -> *Edit bottom bar* (up to five apps, in the order you tap them; it is saved to your account). **More** opens the **Apps** sheet: every app as a big icon, grouped, with the ones you used lately on top, a search box (typing *rad* finds Radio even though it lives under Listen) and unread badges. Home still shows one tile for each part. On a tablet or computer the side menu uses the same merged entries, and Ctrl/Cmd-K finds every page by name.
- **Signal**: your Signal chats inside the app, **on desktop-class screens only** (a window at least 900 px wide with a mouse or trackpad; phones and touch tablets don't
see it). Besides the Signal tab there is a chat button on **every** screen that opens a panel over whatever you are doing (close it with the x; the
expand button opens the full page). New messages get your attention with a toast, a chime, the unread count in the browser tab title and on the installed app's icon, and
+39
View File
@@ -0,0 +1,39 @@
// Bible: the text itself (the Berean Standard Bible, public domain) is a static file the browser loads once (public/vendor/bible/bsb.json); this keeps each person's
// place, highlights, notes and bookmarks on the server so they follow them between devices.
const fs = require('fs'), path = require('path');
const users = require('./users');
const file = () => path.join(users.dir(), 'bible.json');
const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [] }; } catch { return { pos: null, marks: [] }; } };
const save = o => { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const BOOK = /^[A-Z0-9]{3}$/;
/** Merge by id; for each id the newest edit (or deletion) wins, so two devices can add highlights without losing each other's. */
function mergeMarks(have, sent) {
const by = new Map((have || []).map(m => [m.id, m]));
for (const m of Array.isArray(sent) ? sent.slice(0, 3000) : []) {
if (!m || !/^[\w-]{1,40}$/.test(m.id || '') || !BOOK.test(m.b || '')) continue;
const v = Math.max(1, Math.round(+m.v) || 1), clean = { id: m.id, t: +m.t || 0, type: ['hl', 'note', 'mark'].includes(m.type) ? m.type : 'hl', b: m.b, c: Math.max(1, Math.round(+m.c) || 1), v, ve: Math.max(v, Math.round(+m.ve) || v),
color: /^[a-z]{3,12}$/.test(m.color || '') ? m.color : 'yellow', note: clip(m.note, 5000), text: clip(m.text, 1500), del: !!m.del };
const old = by.get(m.id); if (!old || clean.t >= old.t) by.set(m.id, clean);
}
return [...by.values()].filter(m => !m.del || Date.now() - m.t < 30 * 864e5);
}
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 2e6) return null; c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { return undefined; } };
async function handle(req, res) {
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del) }); }
if (p === '/pos' && req.method === 'POST') { // where you are reading; the newest report wins
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load();
if (BOOK.test(b.b || '') && (+b.t || 0) >= ((s.pos || {}).t || 0)) s.pos = { b: b.b, c: Math.max(1, Math.round(+b.c) || 1), v: Math.max(0, Math.round(+b.v) || 0), t: +b.t || Date.now() };
save(s); return json(res, 200, s.pos);
}
if (p === '/marks' && req.method === 'POST') {
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load(); s.marks = mergeMarks(s.marks, b); save(s); return json(res, 200, s.marks.filter(m => !m.del));
}
return json(res, 404, { error: 'Not found' });
}
module.exports = { handle, mergeMarks };
+29 -11
View File
@@ -6,15 +6,16 @@ const atomicWrite = require('./atomic');
const path = require('path');
const crypto = require('crypto');
const cfg = { api: '', dir: '' };
const cfg = { api: '', dir: '', lrc: '' };
const HIT_DAYS = 90, MISS_HOURS = 24;
const inflight = new Map();
function init({ dataDir, apiUrl }) {
cfg.api = (apiUrl || '').replace(/\/$/, ''); cfg.dir = path.join(dataDir, 'lyrics');
if (cfg.api) fs.mkdirSync(cfg.dir, { recursive: true, mode: 0o700 });
cfg.lrc = process.env.LYRICS_LRCLIB === '0' ? '' : (process.env.LRCLIB_URL || 'https://lrclib.net').replace(/\/+$/, ''); // LRCLIB: free synced and plain lyrics, no key (set LYRICS_LRCLIB=0 to never ask it)
if (cfg.api || cfg.lrc) fs.mkdirSync(cfg.dir, { recursive: true, mode: 0o700 });
}
const enabled = () => !!cfg.api;
const enabled = () => !!(cfg.api || cfg.lrc);
const norm = s => String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/\(.*?\)|\[.*?\]/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
const keyOf = (artist, title) => crypto.createHash('sha1').update(norm(artist) + '|' + norm(title)).digest('hex');
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
@@ -62,6 +63,19 @@ function shape(d, want) {
return { found: true, source: src, sync, lines, album: x.album || '' };
}
/** LRCLIB: the exact song first, then a search for the closest title. Synced lines when it has them, otherwise plain words. */
async function lrclib(artist, title, seconds) {
const get = async p => { const r = await fetch(cfg.lrc + p, { headers: { 'user-agent': 'NE-Hub/1.0 (self-hosted)' }, signal: AbortSignal.timeout(15000) }); return r.ok ? r.json().catch(() => null) : null; };
let x = await get('/api/get?' + new URLSearchParams({ artist_name: artist, track_name: title }));
if (!x || x.statusCode) { const list = await get('/api/search?' + new URLSearchParams({ track_name: title, ...(artist ? { artist_name: artist } : {}) })); x = Array.isArray(list) ? list.find(c => norm(c.trackName) === norm(title) && (!artist || norm(c.artistName).includes(norm(artist).split(' ')[0]) || norm(artist).includes(norm(c.artistName).split(' ')[0]))) : null; }
if (!x) return { found: false, why: 'not found' };
if (x.instrumental) return { found: true, instrumental: true, source: 'LRCLIB', sync: 'none', lines: [] };
if (seconds && x.duration && Math.abs(+x.duration - seconds) > 10) return { found: false, why: 'different version' };
const timed = /\[\d+:\d{2}/.test(x.syncedLyrics || '') ? parseLrc(x.syncedLyrics) : [];
if (timed.length >= 2) return { found: true, source: 'LRCLIB', sync: 'line', lines: timed.filter(l => l.text), album: x.albumName || '' };
const lines = String(x.plainLyrics || '').split(/\r?\n/).map(text => ({ text: text.trim() })); return lines.some(l => l.text) ? { found: true, source: 'LRCLIB', sync: 'none', lines, album: x.albumName || '' } : { found: false, why: 'empty' };
}
async function lookup(artist, title, seconds) {
const k = keyOf(artist, title), f = path.join(cfg.dir, k + '.json');
try { // already known?
@@ -70,14 +84,18 @@ async function lookup(artist, title, seconds) {
} catch { /* not cached */ }
if (inflight.has(k)) return inflight.get(k);
const p = (async () => {
const u = new URL(cfg.api + '/lyrics/'); // sources that give real lyrics, Musixmatch's word-synced first; never YouTube captions
u.search = new URLSearchParams({ artist, song: title, timestamps: 'true', word: 'true', sequence: '7,2,4,5,1', pass: 'true' });
const r = await fetch(u, { signal: AbortSignal.timeout(45000) });
if (r.status === 429) throw Object.assign(new Error('The lyrics service is busy; try again in a minute'), { status: 429 });
const d = await r.json().catch(() => null);
if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 });
const result = shape(d, seconds);
try { atomicWrite(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ }
let result = { found: false, why: 'not found' };
if (cfg.api) {
const u = new URL(cfg.api + '/lyrics/'); // sources that give real lyrics, Musixmatch's word-synced first; never YouTube captions
u.search = new URLSearchParams({ artist, song: title, timestamps: 'true', word: 'true', sequence: '7,2,4,5,1', pass: 'true' });
const r = await fetch(u, { signal: AbortSignal.timeout(45000) });
if (r.status === 429) throw Object.assign(new Error('The lyrics service is busy; try again in a minute'), { status: 429 });
const d = await r.json().catch(() => null);
if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 });
result = shape(d, seconds);
}
if (!result.found && cfg.lrc && result.why !== 'different version') { try { const alt = await lrclib(artist, title, seconds); if (alt.found || !cfg.api) result = alt; } catch (e) { if (!cfg.api) throw Object.assign(new Error('The lyrics service is not answering'), { status: 502 }); } }
if (result.why !== 'different version') { try { atomicWrite(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ } } // (whether a version is "different" depends on the length asked about, so that answer is not kept)
return result;
})().finally(() => inflight.delete(k));
inflight.set(k, p); return p;
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles; do node tools/$t-test.js || exit 1; done",
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
+71 -20
View File
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins']);
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']);
const outbox = new Map(); let outTimer = null, warned = false;
function flushSettings() {
outTimer = null;
@@ -37,7 +37,9 @@ async function loadMe() {
}
// Signal is for desktop-class screens only (a window at least 900 px wide with a mouse or trackpad), and needs the server to have it set up.
const sgMQ = matchMedia('(min-width: 900px) and (pointer: fine)');
const allowed = id => id === 'planner' ? (allowed('calendar') || allowed('tasks')) : id === 'links' ? (!ME || ME.tabs.includes('links') || ME.tabs.includes('verse')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
// Related tabs share one menu entry (a row of buttons at the top of the page switches between them); each still needs its own permission.
const MERGED = { listen: ['Listen', ['music', 'radio', 'podcast', 'audio']], reading: ['Read', ['bible', 'read']] };
const allowed = id => MERGED[id] ? MERGED[id][1].some(allowed) : id === 'planner' ? (allowed('calendar') || allowed('tasks')) : id === 'links' ? (!ME || ME.tabs.includes('links') || ME.tabs.includes('verse')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
async function loadSettings() {
try {
@@ -101,6 +103,7 @@ const ICONS = {
bookmark: '<path d="M7 4h10v16l-5-4-5 4z"/>',
radio: '<rect x="3" y="8" width="18" height="12" rx="2"/><path d="M7 8l10-4M8 14h5M16 14h.01M16 17h.01"/>',
heart: '<path d="M12 20s-7-4.4-7-10a4 4 0 017-2.5A4 4 0 0119 10c0 5.6-7 10-7 10z"/>',
bible: '<path d="M5 5a2 2 0 012-2h12v15H7a2 2 0 00-2 2z"/><path d="M5 20a2 2 0 002 2h12v-4M12 7v7M9.5 9.5h5"/>',
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
font: '<path d="M3 19l5-13 5 13M5 14h6M15 19l3.5-9 3.5 9M16.2 16h4.6"/>',
};
@@ -157,19 +160,30 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
// ---- Navigation
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry (each part still needs its own permission). */
const navTabs = () => { const out = []; let done = false; for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; if (t[0] === 'tasks' || t[0] === 'calendar') { if (!done) { out.push(['planner', 'Planner']); done = true; } continue; } out.push(t); } return out; };
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1];
if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; }
if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; };
/** Every page one by one (what search and the command palette list), with the merged entries split back out. */
const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0]));
const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1];
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['music', 'video', 'audio', 'podcast', 'radio', 'read']], ['', ['settings']]];
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['listen', 'reading', 'video']], ['', ['settings']]];
const navGroups = (tabs = navTabs()) => {
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
return out.filter(g => g.tabs.length);
};
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
const PRIMARY = ['home', 'files', 'planner', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
// The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More".
const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail'];
const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); };
const navRecent = () => { try { const l = JSON.parse(localStorage.getItem(nk('nav_recent'))); return Array.isArray(l) ? l : []; } catch { return []; } };
const navTouch = id => { if (!id || id === 'home' || id === 'settings') return; try { localStorage.setItem(nk('nav_recent'), JSON.stringify([id, ...navRecent().filter(x => x !== id)].slice(0, 8))); } catch {} };
const phone = matchMedia('(max-width: 767px)');
let activeTab = 'home';
@@ -182,10 +196,10 @@ function renderNav(active = activeTab) {
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
const mine = navTabs();
if (phone.matches) {
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active);
$('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') +
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); };
} else {
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
$('#navcol').onclick = () => setNav({ mini: !navPref().mini });
@@ -193,14 +207,48 @@ function renderNav(active = activeTab) {
}
phone.addEventListener('change', () => renderNav());
function openMore() {
const rest = navTabs().filter(t => !PRIMARY.includes(t[0]));
const rowOf = ([id, label]) => `<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `<i class="nbadge" style="position:static">${id === 'signal' ? sg.unread : mailUnread}</i>` : ''}${icon('chev', 'chev')}</a>`;
const el = openSheet('More', '', navGroups(rest).map(g => `${g.label ? `<h2 style="margin:14px 0 6px">${esc(g.label)}</h2>` : ''}<div class="list" ${g.label ? '' : 'style="margin-top:10px"'}>${g.tabs.map(rowOf).join('')}</div>`).join(''));
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
/** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */
function openLauncher() {
const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; };
const tile = ([id, label]) => `<a class="lt" href="#/${id}"><span class="lti" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}${badge(id)}</span><span class="ltl">${esc(label)}</span></a>`;
const el = openSheet('Apps', '', `<input type="search" id="lnq" placeholder="Search apps" autocomplete="off" autocapitalize="off" aria-label="Search apps"><div id="lnb"></div>
<div class="bar" style="margin-bottom:0"><button class="btn ghost" id="lnpin" style="flex:1;justify-content:center">${icon('menu')}Edit bottom bar</button></div>`);
el.querySelector('.sheet').classList.add('launcher');
const box = el.querySelector('#lnb'), draw = q => {
q = q.trim().toLowerCase();
if (q) { const hits = leafTabs().filter(t => t[1].toLowerCase().includes(q)).map(([id, l]) => [id, l]); box.innerHTML = hits.length ? `<div class="lgrid">${hits.map(tile).join('')}</div>` : '<p class="empty">No app matches.</p>'; }
else {
const rec = navRecent().map(id => tabs.find(t => t[0] === id)).filter(Boolean).slice(0, 4);
box.innerHTML = (rec.length ? `<h2>Recent</h2><div class="lgrid">${rec.map(tile).join('')}</div>` : '') + navGroups(tabs).map(g => `${g.label ? `<h2>${esc(g.label)}</h2>` : ''}<div class="lgrid" ${g.label ? '' : 'style="margin-top:14px"'}>${g.tabs.map(tile).join('')}</div>`).join('');
}
box.querySelectorAll('a.lt').forEach(a => a.addEventListener('click', closeSheet));
};
draw(''); el.querySelector('#lnq').oninput = e => draw(e.target.value); setTimeout(() => { if (document.activeElement && document.activeElement.id === 'lnq') document.activeElement.blur(); }, 0); // (no keyboard popping up until you tap the box)
el.querySelector('#lnpin').onclick = () => { closeSheet(); openPinsSheet(); };
}
/** Choose which entries sit on the phone's bottom bar (up to five, in the order you tap them). */
function openPinsSheet() {
const tabs = navTabs(); let pins = navPins();
const el = openSheet('Bottom bar', 'Tap up to five apps for the bar, in the order you want them. Everything else stays under More.', '<div class="list" id="pnl"></div><div class="bar"><button class="btn ghost" id="pnr" style="flex:1;justify-content:center">Reset</button><button class="btn" id="pns" style="flex:1;justify-content:center">Save</button></div>');
const draw = () => { el.querySelector('#pnl').innerHTML = tabs.map(([id, label]) => { const at = pins.indexOf(id); return `<button class="row" data-id="${id}"><span class="ic" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}</span><span class="grow"><b>${esc(label)}</b></span><span class="pinno${at >= 0 ? ' on' : ''}">${at >= 0 ? at + 1 : ''}</span></button>`; }).join('');
el.querySelectorAll('#pnl .row').forEach(b => b.onclick = () => { const id = b.dataset.id; if (pins.includes(id)) pins = pins.filter(x => x !== id); else if (pins.length >= 5) return toast('The bar has room for five'); else pins.push(id); draw(); }); };
draw();
el.querySelector('#pnr').onclick = () => { pins = DEFAULT_PINS.filter(id => tabs.some(t => t[0] === id)); draw(); };
el.querySelector('#pns').onclick = () => { store.set('nav_pins', pins.length ? pins : DEFAULT_PINS); closeSheet(); renderNav(); toast('Bottom bar saved'); };
}
/** Where a "Listen" or "Read" tap goes: the part you used last. */
const lastSub = g => { let l = null; try { l = JSON.parse(localStorage.getItem(nk('nav_last_' + g))); } catch {} return MERGED[g][1].includes(l) && allowed(l) ? l : MERGED[g][1].find(allowed); };
/** The row of buttons under the top bar that switches between the parts of Listen and Read. */
function drawSubnav(name) {
const el = $('#subnav'); if (!el) return; const g = leafOf(name), items = g ? MERGED[g][1].filter(allowed) : [];
if (!g || items.length < 2) { el.hidden = true; el.innerHTML = ''; return; }
try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {}
el.innerHTML = items.map(id => `<a href="#/${id}" class="${id === name ? 'on' : ''}" ${id === name ? 'aria-current="page"' : ''}>${esc(tabLabel(id))}</a>`).join(''); el.hidden = false;
}
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
let curRoute = '';
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
@@ -228,7 +276,7 @@ function drawNav() {
$('#nvf').onclick = () => history.forward();
}
$('#nvb').disabled = !canBack; $('#nvf').disabled = !canFwd;
const t = TABS.find(x => x[0] === activeTab); $('#nvt').textContent = t ? t[1] : '';
const t = TABS.find(x => x[0] === (curRoute || activeTab)); $('#nvt').textContent = t ? t[1] : '';
el.hidden = false;
}
addEventListener('popstate', e => { if (e.state && e.state.i != null) { navIdx = e.state.i; drawNav(); } });
@@ -244,7 +292,8 @@ function route() {
document.body.classList.remove('inedit', 'onaudio');
sg.timers.forEach(clearInterval); sg.timers = []; // (Signal screens start their own)
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
renderNav(name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : name);
curRoute = name; navTouch(navIdOf(name)); drawSubnav(name);
renderNav(navIdOf(name));
drawNav();
routes[name](rest.map(decodeURIComponent));
sgDockSync(name);
@@ -273,7 +322,9 @@ function homeApps() {
links: () => tile('#/links', '#0f9d9a', 'link', 'Links', allLinks().length + ' saved'),
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
};
return navGroups().filter(g => g.tabs.some(t => T[t[0]])).map(g => `<h2>${esc(g.label || 'Apps')}</h2><div class="grid">${g.tabs.map(t => T[t[0]] ? T[t[0]]() : '').join('')}</div>`).join('');
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
const HOME_GROUPS = [['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video']]];
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
}
function viewHome() {
+170
View File
@@ -0,0 +1,170 @@
'use strict';
// ---- Bible: the Berean Standard Bible (public domain, https://berean.bible/). The text is one file the browser loads once (public/vendor/bible/bsb.json) and keeps,
// so reading is instant and works offline. Your place, highlights and notes are kept on the server, so they follow you between devices.
const bbApi = (p, opts) => jfetch('/api/bible' + p, opts);
const bbOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
const BB_COLORS = { yellow: '#ffd43b', green: '#69db7c', blue: '#74c0fc', pink: '#f783ac' };
const BB_ABBR = { gen: 'GEN', ge: 'GEN', gn: 'GEN', ex: 'EXO', exo: 'EXO', exod: 'EXO', lev: 'LEV', lv: 'LEV', num: 'NUM', nm: 'NUM', nu: 'NUM', deut: 'DEU', dt: 'DEU', deu: 'DEU', josh: 'JOS', jos: 'JOS', judg: 'JDG', jdg: 'JDG', jg: 'JDG', ru: 'RUT', '1sam': '1SA', '1sa': '1SA', '2sam': '2SA', '2sa': '2SA', '1ki': '1KI', '1kgs': '1KI', '1kings': '1KI', '2ki': '2KI', '2kgs': '2KI', '2kings': '2KI', '1chr': '1CH', '1ch': '1CH', '2chr': '2CH', '2ch': '2CH',
neh: 'NEH', est: 'EST', esth: 'EST', ps: 'PSA', psa: 'PSA', pss: 'PSA', psalm: 'PSA', psalms: 'PSA', prov: 'PRO', pr: 'PRO', pro: 'PRO', eccl: 'ECC', ecc: 'ECC', qoh: 'ECC', song: 'SNG', sos: 'SNG', sng: 'SNG', songofsongs: 'SNG', isa: 'ISA', is: 'ISA', jer: 'JER', lam: 'LAM', ezek: 'EZK', eze: 'EZK', ezk: 'EZK', dan: 'DAN', dn: 'DAN', hos: 'HOS', joel: 'JOL', jol: 'JOL', amos: 'AMO', amo: 'AMO', obad: 'OBA', oba: 'OBA', jonah: 'JON', jon: 'JON', mic: 'MIC', nah: 'NAM', nam: 'NAM', hab: 'HAB', zeph: 'ZEP', zep: 'ZEP', hag: 'HAG', zech: 'ZEC', zec: 'ZEC', mal: 'MAL',
matt: 'MAT', mt: 'MAT', mat: 'MAT', mark: 'MRK', mk: 'MRK', mrk: 'MRK', luke: 'LUK', lk: 'LUK', luk: 'LUK', john: 'JHN', jn: 'JHN', jhn: 'JHN', ac: 'ACT', act: 'ACT', rom: 'ROM', ro: 'ROM', rm: 'ROM', '1cor': '1CO', '1co': '1CO', '2cor': '2CO', '2co': '2CO', gal: 'GAL', ga: 'GAL', eph: 'EPH', phil: 'PHP', php: 'PHP', pp: 'PHP', col: 'COL', '1thess': '1TH', '1th': '1TH', '2thess': '2TH', '2th': '2TH', '1tim': '1TI', '1ti': '1TI', '2tim': '2TI', '2ti': '2TI', tit: 'TIT', philem: 'PHM', phm: 'PHM', heb: 'HEB', jas: 'JAS', jm: 'JAS', '1pet': '1PE', '1pe': '1PE', '2pet': '2PE', '2pe': '2PE', '1john': '1JN', '1jn': '1JN', '2john': '2JN', '2jn': '2JN', '3john': '3JN', '3jn': '3JN', jud: 'JUD', rev: 'REV', re: 'REV', revelation: 'REV' };
let bb = null, bbLoading = null, bbSt = { pos: null, marks: [] }, bbSel = new Set(), bbCur = null, bbPosT = 0;
const bbPrefs = () => { try { return { size: 19, serif: true, ...JSON.parse(localStorage.getItem('bb-prefs') || '{}') }; } catch { return { size: 19, serif: true }; } };
const bbSetPrefs = p => { try { localStorage.setItem('bb-prefs', JSON.stringify(p)); } catch {} };
const bbUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const bbBook = id => bb.books.find(b => b.id === id);
const bbRef = (b, c, v, ve) => `${bbBook(b).name} ${c}${v ? ':' + v + (ve && ve > v ? '-' + ve : '') : ''}`;
function bbLoad() {
if (bb) return Promise.resolve(bb);
return bbLoading || (bbLoading = fetch('/vendor/bible/bsb.json').then(r => { if (!r.ok) throw new Error('Could not load the Bible text'); return r.json(); }).then(d => { bb = d; return d; }).catch(e => { bbLoading = null; throw e; }));
}
/** "John 3:16", "jn 3", "1 cor 13:4-7", "Psalm 23": a book (and chapter, and verse) typed in words. */
function bbParseRef(q) {
const m = /^\s*((?:[1-3]\s*)?[a-z][a-z .]*?)\s*(\d+)?\s*(?:[:.]\s*(\d+)(?:\s*[-–]\s*(\d+))?)?\s*$/i.exec(q.replace(/\bchapter\b|\bverse\b/gi, ' ')); if (!m) return null;
const key = m[1].toLowerCase().replace(/[^a-z0-9]/g, ''); if (key.length < 2) return null;
let id = BB_ABBR[key]; if (!id) { const hit = bb.books.find(b => b.name.toLowerCase().replace(/[^a-z0-9]/g, '') === key) || (key.length >= 3 ? bb.books.find(b => b.name.toLowerCase().replace(/[^a-z0-9]/g, '').startsWith(key)) : null); id = hit && hit.id; }
const book = id && bbBook(id); if (!book) return null; const c = Math.min(book.chapters.length, Math.max(1, +m[2] || 1)), v = m[3] ? +m[3] : 0;
return { b: book.id, c, v, ve: m[4] ? +m[4] : v };
}
// ---- drawing a chapter
function bbVerseHtml(n, parts, notes, showNo) {
const poem = parts.some(Array.isArray), vn = `<sup class="vn">${n}</sup>`, mark = p => `<sup class="fn" data-v="${n}" data-i="${p - 1}" role="button" aria-label="Footnote">${String.fromCharCode(96 + Math.min(p, 26))}</sup>`;
if (!poem) return (showNo ? vn : '') + parts.map(p => typeof p === 'string' ? (p === '\n' ? '<br>' : esc(p)) : mark(p)).join('');
const lines = []; // poetry: one line per part; a footnote marker stays at the end of its line
for (const p of parts) {
if (Array.isArray(p)) lines.push({ l: p[0], h: esc(p[1]) });
else if (p === '\n') continue;
else { if (!lines.length) lines.push({ l: 1, h: '' }); lines[lines.length - 1].h += typeof p === 'string' ? esc(p) : mark(p); }
}
return lines.map((x, i) => `<span class="pl l${x.l}">${i === 0 && showNo ? vn : ''}${x.h}</span>`).join('');
}
function bbChapterHtml(book, c) {
let html = '', para = ''; const flush = () => { if (para.trim()) html += `<p class="bp">${para}</p>`; para = ''; };
for (const b of book.chapters[c - 1]) {
if (b[0] === 'h') { flush(); html += `<h3 class="bh">${esc(b[1])}</h3>`; }
else if (b[0] === 'p') flush();
else if (b[0] === 's') { flush(); html += `<p class="bsub">${esc(b[1])}</p>`; }
else if (b[0] === 'v') {
if (b[2].some(Array.isArray)) { flush(); html += `<div class="bpoem vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</div>`; }
else para += `<span class="vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</span> `;
}
}
flush(); return html;
}
const bbMarksHere = (b, c) => bbSt.marks.filter(m => m.b === b && m.c === c && !m.del);
function bbPaint(root) { // highlights, notes and picked verses, on top of the text
const here = bbMarksHere(bbCur.b, bbCur.c);
root.querySelectorAll('.vs').forEach(el => {
const v = +el.dataset.v, hl = here.find(m => m.type === 'hl' && m.v === v), nt = here.some(m => m.type === 'note' && v >= m.v && v <= m.ve);
el.className = el.className.replace(/\b(hl-\w+|hasnote|pick)\b/g, '').replace(/\s+/g, ' ').trim() + (hl ? ' hl-' + hl.color : '') + (nt ? ' hasnote' : '') + (bbSel.has(v) ? ' pick' : '');
});
bbDrawBar();
}
async function viewBible(args = []) {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Bible</h1><p class="empty">Loading the Bible…</p></div>';
try { await bbLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Bible</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="bbretry">Try again</button></div></div>`; $('#bbretry').onclick = () => route(); return; }
if (!location.hash.startsWith('#/bible')) return;
try { const s = await bbApi('/state'); bbSt = s; } catch {}
if (args[0] === 'marks') return bbMarks();
let b = args[0] && bbBook(args[0]) ? args[0] : null, c = +args[1] || 1, vs = +args[2] || 0;
if (!b) { const mine = (() => { try { return JSON.parse(localStorage.getItem(nk('bb-last'))); } catch { return null; } })(), p = mine && (!bbSt.pos || mine.t > bbSt.pos.t) ? mine : bbSt.pos; b = p && bbBook(p.b) ? p.b : 'JHN'; c = p ? p.c : 1; vs = 0; return location.replace('#/bible/' + b + '/' + c); }
const book = bbBook(b); c = Math.min(book.chapters.length, Math.max(1, c)); bbCur = { b, c }; bbSel = new Set();
const P = bbPrefs(), name = book.name, last = c === book.chapters.length, order = bb.books.findIndex(x => x.id === b);
const prev = c > 1 ? [b, c - 1] : order > 0 ? [bb.books[order - 1].id, bb.books[order - 1].chapters.length] : null, next = !last ? [b, c + 1] : order < 65 ? [bb.books[order + 1].id, 1] : null, href = x => '#/bible/' + x[0] + '/' + x[1];
v.innerHTML = `<div class="wrap bbwrap"><div class="bbbar"><button class="btn ghost" id="bbpick" aria-label="Choose a book and chapter">${icon('bible')}<b>${esc(name)} ${c}</b></button><span class="grow"></span>
<button class="ibtn" id="bbsrch" aria-label="Search the Bible" title="Search">${icon('search')}</button><button class="ibtn" id="bbmk" aria-label="My highlights and notes" title="My highlights and notes">${icon('bookmark')}</button><button class="ibtn" id="bbaa" aria-label="Text size" title="Text size">${icon('font')}</button></div>
<article id="bbtext" class="bbtext ${P.serif ? 'serif' : ''}" style="font-size:${P.size}px">${bbChapterHtml(book, c)}</article>
<div class="bbnav">${prev ? `<a class="btn ghost" href="${href(prev)}">${icon('back')}${esc(bbBook(prev[0]).name)} ${prev[1]}</a>` : '<span></span>'}${next ? `<a class="btn ghost" href="${href(next)}">${esc(bbBook(next[0]).name)} ${next[1]}${icon('chev')}</a>` : '<span></span>'}</div>
<p class="sub" style="text-align:center;margin:18px 0 0;font-size:12px">Berean Standard Bible (BSB), public domain · <a href="https://berean.bible/" target="_blank" rel="noopener" style="color:var(--accent)">berean.bible</a></p><div class="rdsel bbsel" id="bbsel" hidden></div></div>`;
const root = $('#bbtext'); bbPaint(root);
if (vs) { const el = root.querySelector(`.vs[data-v="${vs}"]`); if (el) { setTimeout(() => el.scrollIntoView({ block: 'center' }), 50); el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 2200); } }
root.addEventListener('click', e => {
const fn = e.target.closest('.fn'); if (fn) { const n = bbNote(b, c, +fn.dataset.v, +fn.dataset.i); if (n) openSheet(bbRef(b, c, +fn.dataset.v), '', `<p style="font-size:17px;line-height:1.5">${esc(n)}</p>`); return; }
const el = e.target.closest('.vs'); if (!el) { bbSel.clear(); bbPaint(root); return; } const n = +el.dataset.v; if (bbSel.has(n)) bbSel.delete(n); else bbSel.add(n); bbPaint(root);
});
$('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbaa').onclick = () => bbTextSheet(root);
let x0 = 0, y0 = 0, t0 = 0; root.addEventListener('touchstart', e => { x0 = e.changedTouches[0].clientX; y0 = e.changedTouches[0].clientY; t0 = Date.now(); }, { passive: true });
root.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - x0, dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2 && Date.now() - t0 < 600 && !bbSel.size) { const to = dx < 0 ? next : prev; if (to) location.hash = href(to); } });
bbCur.next = next && href(next); bbCur.prev = prev && href(prev);
const here = { b, c, v: 0, t: Date.now() }; try { localStorage.setItem(nk('bb-last'), JSON.stringify(here)); } catch {}
clearTimeout(bbPosT); bbPosT = setTimeout(() => { fetch('/api/bible/pos', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(here) }).catch(() => {}); bbSt.pos = here; }, 1000);
}
addEventListener('keydown', e => { if (!location.hash.startsWith('#/bible') || !bbCur || /^(INPUT|TEXTAREA|SELECT)$/.test((e.target.tagName || '')) || document.getElementById('sheet')) return; if (e.key === 'ArrowRight' && bbCur.next) location.hash = bbCur.next; else if (e.key === 'ArrowLeft' && bbCur.prev) location.hash = bbCur.prev; });
const bbNote = (b, c, v, i) => { const blk = bbBook(b).chapters[c - 1].find(x => x[0] === 'v' && x[1] === v); return blk && blk[3] ? blk[3][i] : ''; };
// ---- the verses you tapped: highlight, note, copy
function bbDrawBar() {
const bar = $('#bbsel'); if (!bar) return; if (!bbSel.size) { bar.hidden = true; return; }
const vs = [...bbSel].sort((a, b) => a - b), hasHl = bbMarksHere(bbCur.b, bbCur.c).some(m => m.type === 'hl' && bbSel.has(m.v));
bar.hidden = false; bar.innerHTML = `<span class="bbref">${esc(bbRef(bbCur.b, bbCur.c, vs[0], vs[vs.length - 1]))}${vs.length > 1 && vs[vs.length - 1] - vs[0] + 1 !== vs.length ? ' …' : ''}</span>` +
Object.entries(BB_COLORS).map(([c, hex]) => `<button class="rdcol" data-c="${c}" style="background:${hex}" aria-label="Highlight ${c}"></button>`).join('') +
`${hasHl ? `<button class="rdsb" data-a="clear">${icon('x')}Clear</button>` : ''}<button class="rdsb" data-a="note">${icon('edit')}Note</button><button class="rdsb" data-a="copy">${icon('copy')}Copy</button>`;
bar.querySelectorAll('.rdcol').forEach(b => b.onclick = () => bbHighlight(b.dataset.c)); bar.querySelector('[data-a=copy]').onclick = bbCopy; bar.querySelector('[data-a=note]').onclick = () => bbNoteDialog(null);
const cl = bar.querySelector('[data-a=clear]'); if (cl) cl.onclick = () => bbHighlight(null);
}
const bbVerseText = (b, c, v) => { const blk = bbBook(b).chapters[c - 1].find(x => x[0] === 'v' && x[1] === v); return blk ? blk[2].map(p => typeof p === 'string' ? (p === '\n' ? ' ' : p) : typeof p === 'number' ? '' : p[1]).join(' ').replace(/\s+/g, ' ').trim() : ''; };
async function bbSaveMarks(list) { try { bbSt.marks = await bbApi('/marks', bbOpt('POST', list)); } catch (e) { toast('Could not save: ' + e.message); } }
async function bbHighlight(color) {
const { b, c } = bbCur, now = Date.now(), out = [];
for (const v of bbSel) { const old = bbSt.marks.find(m => m.b === b && m.c === c && m.type === 'hl' && m.v === v && !m.del);
if (color === null) { if (old) out.push({ ...old, del: true, t: now }); } else out.push({ id: old ? old.id : bbUid(), t: now, type: 'hl', b, c, v, ve: v, color, text: bbVerseText(b, c, v) }); }
bbSel = new Set(); if (out.length) { for (const m of out) { const i = bbSt.marks.findIndex(x => x.id === m.id); if (i >= 0) bbSt.marks[i] = m; else bbSt.marks.push(m); } bbPaint($('#bbtext')); await bbSaveMarks(out); bbPaint($('#bbtext')); } else bbPaint($('#bbtext'));
}
function bbCopy() {
const { b, c } = bbCur, vs = [...bbSel].sort((a, b) => a - b); const text = vs.map(v => (vs.length > 1 ? v + ' ' : '') + bbVerseText(b, c, v)).join(' ');
navigator.clipboard?.writeText(`${text} — ${bbRef(b, c, vs[0], vs[vs.length - 1])} (BSB)`).then(() => toast('Copied'), () => toast('Could not copy')); bbSel = new Set(); bbPaint($('#bbtext'));
}
function bbNoteDialog(existing) { // a note on the picked verses, or edit one that exists
const m = existing, vs = m ? null : [...bbSel].sort((a, b) => a - b), b = m ? m.b : bbCur.b, c = m ? m.c : bbCur.c, v = m ? m.v : vs[0], ve = m ? m.ve : vs[vs.length - 1];
const el = openSheet(bbRef(b, c, v, ve), '', `<blockquote class="rdq">${esc([...Array(ve - v + 1)].map((_, i) => bbVerseText(b, c, v + i)).join(' ').slice(0, 400))}</blockquote><label>Note</label><textarea id="bbnt" rows="5" maxlength="5000" placeholder="Write a note">${esc(m ? m.note : '')}</textarea>
<div class="bar">${m ? '<button class="btn danger" id="bbnd" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" id="bbns" style="flex:1;justify-content:center">Save</button></div>`);
if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#bbns').onclick = async () => { const note = el.querySelector('#bbnt').value.trim(); closeSheet(); const mk = { id: m ? m.id : bbUid(), t: Date.now(), type: 'note', b, c, v, ve, note, color: 'yellow', text: bbVerseText(b, c, v), del: !note };
const i = bbSt.marks.findIndex(x => x.id === mk.id); if (i >= 0) bbSt.marks[i] = mk; else bbSt.marks.push(mk); bbSel = new Set(); await bbSaveMarks([mk]); if ($('#bbtext')) bbPaint($('#bbtext')); else if (location.hash.startsWith('#/bible/marks')) bbMarks(); };
if (m) el.querySelector('#bbnd').onclick = async () => { closeSheet(); const mk = { ...m, del: true, t: Date.now() }; bbSt.marks = bbSt.marks.filter(x => x.id !== m.id); await bbSaveMarks([mk]); if (location.hash.startsWith('#/bible/marks')) bbMarks(); };
}
// ---- choosing a place, searching, your highlights and notes, text size
function bbPicker() {
let testament = bb.books.findIndex(b => b.id === bbCur.b) >= 39 ? 1 : 0, chosen = null;
const el = openSheet('Choose a book', '', '<div id="bpk"></div>'), box = el.querySelector('#bpk');
const draw = () => {
if (chosen) { const book = bbBook(chosen); box.innerHTML = `<p class="sub" style="margin:0 0 10px"><button class="btn ghost" id="bpb" style="min-height:36px">${icon('back')}Books</button> <b>${esc(book.name)}</b> · pick a chapter</p><div class="bgrid">${book.chapters.map((_, i) => `<a href="#/bible/${chosen}/${i + 1}" class="${chosen === bbCur.b && i + 1 === bbCur.c ? 'on' : ''}">${i + 1}</a>`).join('')}</div>`; box.querySelector('#bpb').onclick = () => { chosen = null; draw(); }; box.querySelectorAll('.bgrid a').forEach(a => a.addEventListener('click', closeSheet)); return; }
const list = bb.books.slice(testament ? 39 : 0, testament ? 66 : 39);
box.innerHTML = `<div class="seg" style="margin-top:0"><a href="#" data-t="0" class="${testament ? '' : 'on'}">Old Testament</a><a href="#" data-t="1" class="${testament ? 'on' : ''}">New Testament</a></div><div class="bbooks">${list.map(b => `<button data-b="${b.id}" class="${b.id === bbCur.b ? 'on' : ''}">${esc(b.name)}</button>`).join('')}</div>`;
box.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); testament = +a.dataset.t; draw(); }); box.querySelectorAll('.bbooks button').forEach(b => b.onclick = () => { chosen = b.dataset.b; if (bbBook(chosen).chapters.length === 1) { closeSheet(); location.hash = '#/bible/' + chosen + '/1'; return; } draw(); });
};
draw();
}
function bbSearch() {
const el = openSheet('Search the Bible', 'A word or phrase, or a place such as John 3:16', '<form><input type="search" id="bbq" placeholder="love, or Romans 8, or 1 Cor 13:4-7" autocomplete="off" autocapitalize="off"></form><div id="bbres" class="list rdlist" style="margin-top:10px"></div>');
const inp = el.querySelector('#bbq'), res = el.querySelector('#bbres');
const run = () => {
const q = inp.value.trim(); if (q.length < 2) { res.innerHTML = ''; return; }
const ref = bbParseRef(q), words = q.toLowerCase().split(/\s+/).filter(Boolean), hits = []; let total = 0;
for (const book of bb.books) book.chapters.forEach((ch, ci) => ch.forEach(blk => { if (blk[0] !== 'v') return; const t = bbVerseText(book.id, ci + 1, blk[1]), low = t.toLowerCase(); if (words.every(w => low.includes(w))) { total++; if (hits.length < 120) hits.push({ b: book.id, c: ci + 1, v: blk[1], t }); } }));
const mark = t => { let h = esc(t); for (const w of words) h = h.replace(new RegExp('(' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'ig'), '<mark>$1</mark>'); return h; };
res.innerHTML = (ref ? `<a class="row" href="#/bible/${ref.b}/${ref.c}${ref.v ? '/' + ref.v : ''}"><span class="ic" style="background:#8b5a2b">${icon('bible')}</span><span class="grow"><b>Go to ${esc(bbRef(ref.b, ref.c, ref.v, ref.ve))}</b></span>${icon('chev', 'chev')}</a>` : '') +
(hits.length ? hits.map(h => `<a class="row" href="#/bible/${h.b}/${h.c}/${h.v}"><span class="grow"><b style="white-space:normal;font-weight:400">${mark(h.t)}</b><small>${esc(bbRef(h.b, h.c, h.v))}</small></span></a>`).join('') + (total > hits.length ? `<p class="sub" style="padding:10px 16px;margin:0">Showing ${hits.length} of ${total}. Add a word to narrow it down.</p>` : `<p class="sub" style="padding:10px 16px;margin:0">${total} verse${total === 1 ? '' : 's'}</p>`) : ref ? '' : '<p class="empty">Nothing found.</p>');
res.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
};
let t; inp.oninput = () => { clearTimeout(t); t = setTimeout(run, 250); }; el.querySelector('form').onsubmit = e => { e.preventDefault(); clearTimeout(t); run(); }; inp.focus();
}
async function bbMarks() {
const order = id => bb.books.findIndex(b => b.id === id), list = bbSt.marks.filter(m => !m.del).sort((a, b) => order(a.b) - order(b.b) || a.c - b.c || a.v - b.v);
$('#view').innerHTML = `<div class="wrap bbwrap"><div class="ptop"><a class="btn ghost" href="#/bible">${icon('back')}Bible</a><h1 style="margin:0">My highlights and notes</h1></div>${list.length ? `<div class="list">${list.map(m => `<div class="row bbm" data-id="${esc(m.id)}"><button class="grow bbmj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0"><b>${m.type === 'hl' ? `<span class="rddot" style="background:${BB_COLORS[m.color] || BB_COLORS.yellow}"></span>` : icon('edit')} ${esc(bbRef(m.b, m.c, m.v, m.ve))}</b><small style="white-space:normal;display:block">${esc(m.text)}</small>${m.note ? `<span style="white-space:pre-wrap;display:block;margin-top:3px">${esc(m.note)}</span>` : ''}</button>
${m.type === 'note' ? `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>` : ''}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>` : '<p class="empty">Nothing yet. Tap verses while reading to highlight them or add a note.</p>'}</div>`;
document.querySelectorAll('.bbm').forEach(r => { const m = list.find(x => x.id === r.dataset.id);
r.querySelector('.bbmj').onclick = () => { location.hash = `#/bible/${m.b}/${m.c}/${m.v}`; };
const e = r.querySelector('[data-e]'); if (e) e.onclick = () => bbNoteDialog(m);
r.querySelector('[data-d]').onclick = async () => { const mk = { ...m, del: true, t: Date.now() }; bbSt.marks = bbSt.marks.filter(x => x.id !== m.id); await bbSaveMarks([mk]); bbMarks(); }; });
}
function bbTextSheet(root) {
const P = bbPrefs(), el = openSheet('Text', '', `<label>Size</label><div class="rdstep"><button type="button" class="btn ghost" data-s="-1">A−</button><span id="bbsv">${P.size}</span><button type="button" class="btn ghost" data-s="1">A+</button></div>
<label>Font</label><div class="rdseg"><button type="button" data-f="1" class="${P.serif ? 'on' : ''}">Serif</button><button type="button" data-f="0" class="${P.serif ? '' : 'on'}">Sans-serif</button></div><div class="bar"><button class="btn" id="bbok" style="flex:1;justify-content:center">Done</button></div>`);
const apply = () => { bbSetPrefs(P); root.style.fontSize = P.size + 'px'; root.classList.toggle('serif', P.serif); };
el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { P.size = Math.min(34, Math.max(14, P.size + +b.dataset.s)); el.querySelector('#bbsv').textContent = P.size; apply(); });
el.querySelectorAll('[data-f]').forEach(b => b.onclick = () => { P.serif = b.dataset.f === '1'; el.querySelectorAll('[data-f]').forEach(x => x.classList.toggle('on', x === b)); apply(); }); el.querySelector('#bbok').onclick = closeSheet;
}
+2 -1
View File
@@ -49,7 +49,7 @@
<body>
<div id="shell">
<nav id="nav" aria-label="Main"></nav>
<div id="main"><div id="navbar" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
<div id="main"><div id="navbar" hidden></div><div id="subnav" class="seg" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
</div>
<div id="toast" role="status"></div>
<script src="/webauthn.js"></script>
@@ -75,6 +75,7 @@
<script src="/audiobooks-view.js"></script>
<script src="/librivox-view.js"></script>
<script src="/radio-view.js"></script>
<script src="/bible-view.js"></script>
<script src="/save-to-files.js"></script>
<script src="/vbv-view.js"></script>
<script src="/lyrics-view.js"></script>
+22 -13
View File
@@ -24,9 +24,15 @@ const lyrLabel = () => lyrSpokenKey(typeof cur === 'function' ? cur() : null) ?
const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
const lyrOffset = k => { try { return +localStorage.getItem('lyr_off:' + k) || 0; } catch { return 0; } };
// Radio: lyrics for the song that is on now. A station's own clock says when the song began (The Journey FM does; most do not), and what you hear is a few seconds
// behind that, so radio starts 5 seconds early and the - and + buttons move it a second at a time (remembered for that station).
const lyrIsRadio = t => !!t && String(t.key).startsWith('radio:');
const lyrId = t => lyrIsRadio(t) ? t.key + '|' + t.artist + '|' + t.title : t.key; // (a station is one "track" that plays many songs)
const lyrStep = t => lyrIsRadio(t) ? 1000 : 500;
const lyrPos = t => lyrIsRadio(t) ? Date.now() - ((t.song && t.song.startedAt) || Date.now()) + lyrOffset(t.key) : pl.a.currentTime * 1000 + lyrOffset(t.key);
const lyrOffset = k => { try { const v = localStorage.getItem('lyr_off:' + k); return v === null ? (String(k).startsWith('radio:') ? -5000 : 0) : +v || 0; } catch { return 0; } };
const lyrSetOffset = (k, v) => { try { localStorage.setItem('lyr_off:' + k, String(v)); } catch {} };
const lyrQuery = t => { try { return JSON.parse(localStorage.getItem('lyr_q:' + t.key)) || { artist: t.artist, title: t.title }; } catch { return { artist: t.artist, title: t.title }; } };
const lyrQuery = t => { try { return JSON.parse(localStorage.getItem('lyr_q:' + lyrId(t))) || { artist: t.artist, title: t.title }; } catch { return { artist: t.artist, title: t.title }; } };
const lyrCleanTitle = s => String(s || '').replace(/\s*[\(\[](feat\.?|ft\.?|with|remaster(ed)?|live|deluxe|version|mono|stereo)[^\)\]]*[\)\]]/gi, '').replace(/\s+-\s+(remaster(ed)?|live|mono|stereo|single version).*$/i, '').trim();
async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc file next to it, or downloaded by Plex)
@@ -56,16 +62,19 @@ async function lyrSpoken(t, start) {
await new Promise(ok => setTimeout(ok, 4000));
}
}
const lyrForRadio = (t, res) => lyrIsRadio(t) && res && res.found && res.sync !== 'none' && !(t.song && t.song.startedAt) ? { ...res, sync: 'none' } : res; // a live station only has a clock when it says when the song began
async function lyrLoad(t, force) {
if (!force && lyr.cache.has(t.key)) return lyr.cache.get(t.key);
let res = force ? null : await lyrFromPlex(t);
const id = lyrId(t);
if (!force && lyr.cache.has(id)) return lyrForRadio(t, lyr.cache.get(id));
if (lyrIsRadio(t) && !t.song) return { found: false, error: 'This station does not say which song is playing, so there is nothing to look up.' };
let res = force || lyrIsRadio(t) ? null : await lyrFromPlex(t);
if (!res && cfg.lyrics) {
const q = lyrQuery(t), u = '/api/lyrics?' + new URLSearchParams({ artist: q.artist, title: lyrCleanTitle(q.title), duration: (t.dur || 0) / 1000 || '' });
try { res = await jfetch(u); if (!res.found && lyrCleanTitle(q.title) !== q.title) { /* the cleaned title is what we asked; nothing else to try */ } } catch (e) { res = { found: false, error: e.message }; }
}
res = res || { found: false };
if (!res.error) lyr.cache.set(t.key, res);
return res;
if (!res.error) lyr.cache.set(id, res);
return lyrForRadio(t, res);
}
function lyrOpen() {
@@ -101,13 +110,13 @@ function lyrShell() {
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
$l('#lyrs').onclick = lyrSearchAgain;
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
$l('#lyrm').onclick = () => nudge(-500); $l('#lyrp').onclick = () => nudge(500);
$l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
syncUI();
}
async function lyrReload(force) {
const t = cur(); if (!t || !lyr.el) return; lyr.key = t.key; lyr.data = null; lyr.idx = -2;
const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2;
const body = lyr.el.querySelector('#lyrbody'); body.innerHTML = '<p class="empty">Looking for the lyrics…</p>';
const res = lyrSpokenKey(t) ? await lyrSpoken(t, force === 'start') : await lyrLoad(t, force); if (!res || !lyr.el || lyr.key !== t.key) return;
const res = lyrSpokenKey(t) ? await lyrSpoken(t, force === 'start') : await lyrLoad(t, force); if (!res || !lyr.el || lyr.key !== lyrId(t)) return;
lyr.data = res; lyrDraw();
}
function lyrDraw() {
@@ -123,9 +132,9 @@ function lyrDraw() {
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return;
}
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced');
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced') + (lyrIsRadio(t) && d.sync !== 'none' ? ' · live timing is approximate, use - and +' : '');
body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').join('');
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none') return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none' || lyrIsRadio(t)) return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
body.scrollTop = 0; lyr.idx = -2;
}
function lyrSearchAgain() {
@@ -133,12 +142,12 @@ function lyrSearchAgain() {
const el = openSheet('Search for the lyrics', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
<div class="bar"><button class="btn ghost" type="button" id="lyrc" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Search</button></div></form>`);
el.querySelector('#lyrc').onclick = closeSheet;
el.querySelector('#lyrf').onsubmit = e => { e.preventDefault(); try { localStorage.setItem('lyr_q:' + t.key, JSON.stringify({ artist: el.querySelector('#lyra').value.trim(), title: el.querySelector('#lyrti').value.trim() })); } catch {} closeSheet(); lyr.cache.delete(t.key); lyrReload(true); };
el.querySelector('#lyrf').onsubmit = e => { e.preventDefault(); try { localStorage.setItem('lyr_q:' + lyrId(t), JSON.stringify({ artist: el.querySelector('#lyra').value.trim(), title: el.querySelector('#lyrti').value.trim() })); } catch {} closeSheet(); lyr.cache.delete(lyrId(t)); lyrReload(true); };
}
function lyrTick() { // follows the song: which line is current, and how far into it we are
lyr.raf = requestAnimationFrame(lyrTick);
const d = lyr.data, t = cur(); if (!lyr.el || !d || !d.found || d.sync === 'none' || !t) return;
const pos = pl.a.currentTime * 1000 + lyrOffset(t.key), L = d.lines;
const pos = lyrPos(t), L = d.lines;
let lo = 0, hi = L.length - 1, idx = -1; while (lo <= hi) { const m = (lo + hi) >> 1; if (L[m].t <= pos) { idx = m; lo = m + 1; } else hi = m - 1; }
const body = lyr.el.querySelector('#lyrbody'), nodes = body.querySelectorAll('.ll');
if (idx !== lyr.idx) {
+2 -1
View File
@@ -51,7 +51,8 @@ function openPalette() {
let items = [], sel = 0, seq = 0, timer, live = [];
const go = h => () => { location.hash = h; };
const base = () => {
const out = navTabs().map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === id)) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
const out = navTabs().flatMap(t => MERGED[t[0]] ? MERGED[t[0]][1].filter(allowed).map(id => [id, tabLabel(id)]) : [t]).map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === (leafOf(id) || id))) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
if (phone.matches) out.push({ label: 'Edit the bottom bar', hint: 'View', ic: 'menu', run: () => openPinsSheet(), kw: 'menu tabs pins more' });
if (allowed('tasks') && allowed('calendar')) out.push({ label: 'Go to Tasks', hint: 'Planner', ic: 'check', run: go('#/tasks'), kw: 'todo' }, { label: 'Go to Calendar', hint: 'Planner', ic: 'cal', run: go('#/calendar'), kw: 'agenda events' });
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' });
+19 -6
View File
@@ -4,7 +4,7 @@
const rdoApi = (p, opts) => jfetch('/api/radio' + p, opts);
const rdoOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
const rdoIcon = s => s.favicon ? '/api/radio/icon?u=' + encodeURIComponent(s.favicon) : '';
const rdoTrack = s => ({ key: 'radio:' + s.id, title: s.name, artist: 'Live radio', album: [s.state, s.country].filter(Boolean).join(', '), thumb: rdoIcon(s), src: '/api/radio/play/' + encodeURIComponent(s.id), nocache: true, dur: 0, station: s.name });
const rdoTrack = s => ({ key: 'radio:' + s.id, title: s.name, artist: 'Live radio', album: [s.state, s.country].filter(Boolean).join(', '), album0: [s.state, s.country].filter(Boolean).join(', '), thumb: rdoIcon(s), src: '/api/radio/play/' + encodeURIComponent(s.id), nocache: true, dur: 0, station: s.name });
const rdoPlaying = () => { const t = cur(); return t && String(t.key).startsWith('radio:') ? t : null; };
const rdoMeta = s => [s.tags && s.tags.split(',').slice(0, 2).join(', '), s.state || s.country, s.codec && (s.codec + (s.bitrate ? ' ' + s.bitrate + 'k' : ''))].filter(Boolean).join(' · ');
const rdoChips = [['Christian', 'christian'], ['Worship', 'worship'], ['Gospel', 'gospel'], ['Talk', 'talk'], ['News', 'news'], ['Classical', 'classical'], ['Jazz', 'jazz'], ['Country', 'country'], ['Oldies', 'oldies']];
@@ -16,11 +16,23 @@ async function rdoPoll() {
const id = t.key.slice(6);
try {
const n = await rdoApi('/now/' + encodeURIComponent(id)); if (!rdoPlaying() || rdoPlaying().key !== t.key) return;
rdoNow[id] = n; t.title = n.title || t.station; t.artist = n.title ? (n.artist || t.station) : 'Live radio'; t.album = n.title ? t.station : t.album;
if (typeof updateNow === 'function') updateNow(); const el = document.getElementById('rdonow'); if (el && rdoPlaying()) el.dataset.id === id && rdoDrawNow(el, n);
rdoNow[id] = n; rdoApplyNow(t, n);
const el = document.getElementById('rdonow'); if (el && el.dataset.id === id) rdoDrawNow(el, n);
const sm = document.querySelector(`.rdorow[data-id="${id}"] small`); if (sm && n.title) sm.textContent = 'Now: ' + n.title + (n.artist ? ' \u2014 ' + n.artist : '');
} catch {}
}
setInterval(rdoPoll, 20000); pl.a.addEventListener('playing', () => { if (rdoPlaying()) setTimeout(rdoPoll, 1500); });
/** What is on now goes into the player (the song as the title, the artist under it, the station as the album), the lock screen, and the lyrics if they are open. */
function rdoApplyNow(t, n) {
const was = t.song ? t.song.title + '|' + t.song.artist : '';
t.song = n.title ? { title: n.title, artist: n.artist || '', startedAt: n.startedAt || 0 } : null;
t.title = n.title || t.station; t.artist = n.title ? (n.artist || t.station) : 'Live radio'; t.album = n.title ? t.station : t.album0;
const now = t.song ? t.song.title + '|' + t.song.artist : '';
if (was === now && t.shown) return;
t.shown = true;
for (const f of ['setMeta', 'renderMini', 'updateNow', 'renderNpCard', 'syncUI']) { try { if (typeof window[f] === 'function') (f === 'setMeta' ? window[f](t) : window[f]()); } catch {} }
if (lyr.open && was !== now) { lyrShell(); lyrReload(); }
}
setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPlaying()) setTimeout(rdoPoll, 1500); });
function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `<small>Now playing</small><b>${esc(n.title)}</b>${n.artist ? '<span> · ' + esc(n.artist) + '</span>' : ''}` : '<small>Live</small>'; }
async function viewRadio() {
@@ -36,10 +48,10 @@ async function viewRadio() {
v.innerHTML = `<div class="wrap"><h1>Radio</h1>
<div class="card rdofeat" style="max-width:none"><div class="sub" style="margin:0">Featured</div><h2 style="margin:2px 0 0;font-size:22px;text-transform:none;letter-spacing:0;color:var(--text)">${esc(j.name)}</h2>
<p class="sub" style="margin:2px 0 10px">${esc(rdoMeta(j))}</p><div id="rdonow" data-id="${esc(j.id)}" class="rdonow"><small>Live</small></div>
<div class="bar"><button class="btn" id="rdojp">${icon('play')}Listen live</button><a class="btn ghost" href="${esc(j.homepage)}" target="_blank" rel="noopener">${icon('ext')}Website</a><button class="btn ghost" id="rdojr">Recently played</button></div></div>
<div class="bar"><button class="btn" id="rdojp">${icon('play')}Listen live</button><button class="btn ghost" id="rdojl">${icon('lyrics')}Lyrics</button><a class="btn ghost" href="${esc(j.homepage)}" target="_blank" rel="noopener">${icon('ext')}Website</a><button class="btn ghost" id="rdojr">Recently played</button></div></div>
<div id="rdomine"></div><h2>Find stations</h2><input type="search" id="rdoq" placeholder="Search stations by name" autocomplete="off" autocapitalize="off" style="width:100%">
<div class="seg" id="rdochips">${rdoChips.map(([l, t]) => `<a href="#" data-t="${t}" class="${t === S.tag ? 'on' : ''}">${l}</a>`).join('')}</div><div id="rdores" class="list"></div><div id="rdomore" class="bar" style="justify-content:center"></div></div>`;
const playStation = s => { playQueue([rdoTrack(s)], 0); toast('Tuning in to ' + s.name); drawAll(); setTimeout(rdoPoll, 2500); };
const playStation = s => { const t = rdoTrack(s); playQueue([t], 0); toast('Tuning in to ' + s.name); drawAll(); rdoApi('/now/' + encodeURIComponent(s.id)).then(n => { if (rdoPlaying() && rdoPlaying().key === t.key) { rdoNow[s.id] = n; rdoApplyNow(rdoPlaying(), n); const el = document.getElementById('rdonow'); if (el && el.dataset.id === s.id) rdoDrawNow(el, n); } }).catch(() => {}); setTimeout(rdoPoll, 2500); };
const bindRows = (root, byId) => { root.querySelectorAll('.rdorow').forEach(r => { const s = byId(r.dataset.id); r.querySelector('[data-play]').onclick = () => playStation(s);
const f = r.querySelector('[data-fav]'); if (f) f.onclick = async () => { try { if (saved(s.id)) { await rdoApi('/stations/' + encodeURIComponent(s.id), rdoOpt('DELETE')); rdoSaved = rdoSaved.filter(x => x.id !== s.id); } else { await rdoApi('/stations', rdoOpt('POST', { id: s.id })); rdoSaved.push(s); } drawAll(); } catch (e) { toast(e.message); } }; }); };
const drawMine = () => { $('#rdomine').innerHTML = rdoSaved.length ? `<h2>My stations</h2><div class="list">${rdoSaved.map(s => row(s)).join('')}</div>` : ''; bindRows($('#rdomine'), id => rdoSaved.find(x => x.id === id)); };
@@ -53,6 +65,7 @@ async function viewRadio() {
if (my === S.seq) { S.busy = false; drawRes(); }
};
$('#rdojp').onclick = () => playStation(j);
$('#rdojl').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof lyrOpen === 'function' && !lyr.open) lyrOpen(); }, 1200); };
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty">Loading…</p>'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `<div class="list">${n.recent.map(x => `<div class="row"><span class="grow"><b>${esc(x.title)}</b><small>${esc(x.artist)}</small></span></div>`).join('')}</div>` : '<p class="empty">Nothing to show right now.</p>'; } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; } };
$('#rdochips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.tag = a.dataset.t; S.q = ''; $('#rdoq').value = ''; $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); run(true); });
let t; $('#rdoq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', !S.q && x.dataset.t === S.tag)); run(true); }, 450); };
+37
View File
@@ -902,3 +902,40 @@ body.mselecting .mrow { user-select: none; -webkit-user-select: none; -webkit-to
.rdoplay .art { width: 44px; height: 44px; border-radius: 10px; background: var(--panel2); flex: none; overflow: hidden; display: block; } .rdoplay .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.rdorow .ibtn { margin-right: 6px; }
.rdonow { min-height: 40px; } .rdonow small { display: block; color: var(--muted); } .rdonow b { font-size: 17px; }
/* Menu: Listen/Read sub-buttons, the Apps sheet behind More, choosing the bottom bar */
#subnav { display: flex; gap: 6px; overflow-x: auto; margin: 0; padding: 6px calc(var(--sar) + 12px) 2px calc(var(--sal) + 12px); scrollbar-width: none; flex: none; }
#subnav::-webkit-scrollbar { display: none; } #subnav[hidden], body.editing #subnav { display: none; }
#subnav a { min-height: 36px; padding: 6px 14px; font-size: 14px; }
@media (min-width: 768px) { #subnav { padding-left: 24px; } }
#sheet .sheet.launcher { height: 92dvh; max-height: 92dvh; display: flex; flex-direction: column; }
#sheet .sheet.launcher #lnb { flex: 1; overflow-y: auto; margin: 4px -4px 0; padding: 0 4px; }
#sheet .sheet.launcher #lnq { width: 100%; margin-top: 4px; }
#sheet .sheet.launcher h2 { margin: 16px 0 10px; }
.lgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px 6px; }
.lt { display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; color: inherit; min-width: 0; }
.lti { position: relative; width: 58px; height: 58px; border-radius: 17px; display: grid; place-items: center; color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
.lti svg { width: 27px; height: 27px; } .lt:active .lti { transform: scale(.94); }
.ltl { font-size: 12px; line-height: 1.2; max-width: 100%; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pinno { width: 26px; height: 26px; border-radius: 50%; border: 2px solid var(--line); display: grid; place-items: center; font-weight: 700; font-size: 13px; flex: none; } .pinno.on { background: var(--accent); border-color: var(--accent); color: var(--accent-t); }
/* Bible */
.bbwrap { max-width: 42rem; }
.bbbar { display: flex; align-items: center; gap: 6px; position: sticky; top: -8px; z-index: 3; background: var(--bg); padding: 8px 0; margin-top: -8px; }
.bbbar .btn b { margin-left: 6px; }
.bbtext { line-height: 1.75; padding-bottom: 8px; } .bbtext.serif { font-family: Georgia, "Times New Roman", serif; }
.bbtext .bh { font-size: .82em; font-family: system-ui, sans-serif; text-transform: none; letter-spacing: 0; margin: 1.5em 0 .4em; color: var(--muted); }
.bbtext .bsub { font-style: italic; color: var(--muted); margin: .2em 0 .8em; } .bbtext .bp { margin: 0 0 .9em; }
.bbtext .bpoem { margin: 0 0 .5em; } .bbtext .pl { display: block; padding-left: 1.2em; text-indent: -1.2em; } .bbtext .pl.l2 { padding-left: 2.4em; }
.bbtext .vn { font-size: .6em; font-weight: 700; color: var(--accent); margin-right: .25em; vertical-align: super; font-family: system-ui, sans-serif; }
.bbtext .fn { font-size: .6em; font-weight: 700; color: var(--accent); margin: 0 .1em; cursor: pointer; vertical-align: super; font-family: system-ui, sans-serif; }
.bbtext .vs { border-radius: 3px; -webkit-box-decoration-break: clone; box-decoration-break: clone; cursor: pointer; }
.bbtext .vs.pick { background: rgba(61,107,240,.25); } .bbtext .vs.flash { background: rgba(232,89,12,.28); } .bbtext .vs.hasnote { border-bottom: 2px dotted var(--accent); }
.bbtext .hl-yellow { background: #ffd43b66; } .bbtext .hl-green { background: #69db7c66; } .bbtext .hl-blue { background: #74c0fc66; } .bbtext .hl-pink { background: #f783ac66; }
.bbnav { display: flex; justify-content: space-between; gap: 8px; margin-top: 24px; }
.bbsel.rdsel { position: fixed; bottom: calc(env(safe-area-inset-bottom) + 84px); flex-wrap: wrap; justify-content: center; border-radius: 20px; z-index: 15; }
.bbref { font-weight: 600; font-size: 13px; white-space: nowrap; width: 100%; text-align: center; }
.bbooks { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 4px; max-height: 58dvh; overflow: auto; }
.bbooks button { min-height: 44px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 0 10px; text-align: left; } .bbooks button.on { background: var(--accent); color: var(--accent-t); }
.bgrid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; max-height: 58dvh; overflow: auto; }
.bgrid a { display: grid; place-items: center; height: 46px; border-radius: 10px; background: var(--panel2); color: inherit; font-weight: 600; } .bgrid a.on { background: var(--accent); color: var(--accent-t); }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v44';
const CACHE = 'hub-v46';
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+1
View File
@@ -0,0 +1 @@
{"id":"BSB","name":"Berean Standard Bible","license":"Public domain (dedicated by Bible Hub / Berean Bible, April 30, 2023)","source":"https://berean.bible/","books":[{"id":"GEN","name":"Genesis","chapters":[[["h","The Creation"],["v",1,["In the beginning God created the heavens and the earth."]],["p"],["v",2,["Now the earth was formless and void, and darkness was over the surface of the deep. And the Spirit of God was hovering over the surface of the waters."]],["h","The First Day"],["p"],["v",3,["And God said, “Let there be light,”",1,"and there was light."],["Cited in 2 Corinthians 4:6"]],["v",4,["And God saw that the light was good, and He separated the light from the darkness."]],["v",5,["God called the light “day,” and the darkness He called “night.”","\n","And there was evening, and there was morning—the first day.",1],["Literally day one"]],["h","The Second Day"],["p"],["v",6,["And God said, “Let there be an expanse",1,"between the waters, to separate the waters from the waters.”"],["Or a canopy or a firmament or a vault; also in verses 7, 8, 14, 15, 17, and 20"]],["v",7,["So God made the expanse and separated the waters beneath it from the waters above. And it was so."]],["v",8,["God called the expanse “sky.”","\n","And there was evening, and there was morning—the second day."]],["h","The Third Day"],["p"],["v",9,["And God said, “Let the waters under the sky be gathered into one place, so that the dry land may appear.” And it was so."]],["v",10,["God called the dry land “earth,” and the gathering of waters He called “seas.” And God saw that it was good."]],["p"],["v",11,["Then God said, “Let the earth bring forth vegetation: seed-bearing plants and fruit trees, each bearing fruit with seed according to its kind.” And it was so."]],["v",12,["The earth produced vegetation: seed-bearing plants according to their kinds and trees bearing fruit with seed according to their kinds. And God saw that it was good."]],["p"],["v",13,["And there was evening, and there was morning—the third day."]],["h","The Fourth Day"],["p"],["v",14,["And God said, “Let there be lights in the expanse of the sky to distinguish between the day and the night, and let them be signs to mark the seasons and days and years."]],["v",15,["And let them serve as lights in the expanse of the sky to shine upon the earth.” And it was so."]],["p"],["v",16,["God made two great lights: the greater light to rule the day and the lesser light to rule the night. And He made the stars as well."]],["p"],["v",17,["God set these lights in the expanse of the sky to shine upon the earth,"]],["v",18,["to preside over the day and the night, and to separate the light from the darkness. And God saw that it was good."]],["p"],["v",19,["And there was evening, and there was morning—the fourth day."]],["h","The Fifth Day"],["p"],["v",20,["And God said, “Let the waters teem with living creatures, and let birds fly above the earth in the open expanse of the sky.”"]],["v",21,["So God created the great sea creatures and every living thing that moves, with which the waters teemed according to their kinds, and every winged bird after its kind. And God saw that it was good."]],["p"],["v",22,["Then God blessed them and said, “Be fruitful and multiply and fill the waters of the seas, and let birds multiply on the earth.”"]],["p"],["v",23,["And there was evening, and there was morning—the fifth day."]],["h","The Sixth Day"],["p"],["v",24,["And God said, “Let the earth bring forth living creatures according to their kinds: livestock, land crawlers, and beasts of the earth according to their kinds.” And it was so."]],["v",25,["God made the beasts of the earth according to their kinds, the livestock according to their kinds, and everything that crawls upon the earth according to its kind. And God saw that it was good."]],["p"],["v",26,["Then God said, “Let Us make man in Our image, after Our likeness, to rule over the fish of the sea and the birds of the air, over the livestock, and over all the earth itself",1,"and every creature that crawls upon it.”"],["MT; Syriac and over all the beasts of the earth"]],["p"],["v",27,[[1,"So God created man in His own image;"],[2,"in the image of God He created him;"],"\n",[2,"male and female He created them."],1],["Cited in Matthew 19:4 and Mark 10:6"]],["p"],["v",28,["God blessed them and said to them, “Be fruitful and multiply, and fill the earth and subdue it; rule over the fish of the sea and the birds of the air and every creature that crawls upon the earth.”"]],["p"],["v",29,["Then God said, “Behold, I have given you every seed-bearing plant on the face of all the earth, and every tree whose fruit contains seed. They will be yours for food."]],["v",30,["And to every beast of the earth and every bird of the air and every creature that crawls upon the earth—everything that has the breath of life in it—I have given every green plant for food.” And it was soLine truncated
+15 -4
View File
@@ -63,9 +63,19 @@ const readBody = async req => { const c = []; let n = 0; for await (const d of r
const nowMap = new Map(); // person:station -> { title, t }
const clean = s => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 160);
const titleCase = s => s && s === s.toUpperCase() ? s.toLowerCase().replace(/(^|[\s(&\/-])([a-z])/g, (m, a, b) => a + b.toUpperCase()) : s;
/** "10/24/2026 22:01:37" on the station's clock (Lynchburg, Virginia: US Eastern time) as a moment in time. */
function easternEpoch(s) {
const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4}) (\d{1,2}):(\d{2}):(\d{2})$/.exec(String(s || '').trim()); if (!m) return 0;
const [mo, d, y, h, mi, se] = m.slice(1).map(Number), wall = Date.UTC(y, mo - 1, d, h, mi, se);
const at = ms => { const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' }).formatToParts(new Date(ms)).map(x => [x.type, +x.value])); return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - ms; };
let t = wall - at(wall); t = wall - at(t); return t; // (twice, so the hour the clocks change is right too)
}
const journeyCache = new Map();
async function journeyNow(recent) {
const k = recent ? 'r' : 'n', c = journeyCache.get(k); if (c && Date.now() - c.t < 8000) return c.v; // the station's feed is asked at most every 8 seconds, however many people are listening
const rows = JSON.parse(await readText(JOURNEY_NOW() + (recent ? '/getRecentlyPlayed' : '/getNowPlaying'), 1e6, { timeout: 8000 }));
return (Array.isArray(rows) ? rows : []).filter(r => r && r.TITLE && r.ARTIST).map(r => ({ title: titleCase(clean(r.TITLE)), artist: titleCase(clean(r.ARTIST)), at: r.ACTIVITY_DATE || '' })); // rows with no artist are the station's own sound bites
const v = (Array.isArray(rows) ? rows : []).filter(r => r && r.TITLE && r.ARTIST).map(r => ({ title: titleCase(clean(r.TITLE)), artist: titleCase(clean(r.ARTIST)), at: r.ACTIVITY_DATE || '', startedAt: easternEpoch(r.ACTIVITY_DATE) })); // rows with no artist are the station's own sound bites
journeyCache.set(k, { t: Date.now(), v }); return v;
}
/** Pull the song title out of a stream: every `metaint` bytes of audio are followed by a small text block (StreamTitle='...';), which must not reach the player. */
function icyFilter(metaint, onTitle) {
@@ -100,8 +110,9 @@ async function handle(req, res) {
}
if ((m = /^\/stations\/([\w-]+)$/.exec(p)) && req.method === 'DELETE') { saveMine(mine().filter(x => x.id !== m[1])); return json(res, 200, { removed: true }); }
if ((m = /^\/now\/([\w-]+)$/.exec(p)) && req.method === 'GET') {
if (m[1] === 'journeyfm') { let now = null, recent = []; try { recent = await journeyNow(true); now = recent[0] || null; } catch {} return json(res, 200, { title: now ? now.title : '', artist: now ? now.artist : '', recent: recent.slice(0, 12) }); }
const n = nowMap.get(me.id + ':' + m[1]); return json(res, 200, { title: n && Date.now() - n.t < 600e3 ? n.title : '', artist: '', recent: [] });
if (m[1] === 'journeyfm') { let now = null, recent = []; try { recent = await journeyNow(true); now = recent[0] || null; } catch {} return json(res, 200, { title: now ? now.title : '', artist: now ? now.artist : '', startedAt: now ? now.startedAt : 0, recent: recent.slice(0, 12) }); }
const n = nowMap.get(me.id + ':' + m[1]); const fresh = n && Date.now() - n.t < 600e3, sp = fresh ? /^(.+?)\s+-\s+(.+)$/.exec(n.title) : null; // many stations send "Artist - Title"
return json(res, 200, { title: fresh ? (sp ? sp[2] : n.title) : '', artist: sp ? sp[1] : '', startedAt: 0, recent: [] });
}
if (p === '/icon' && req.method === 'GET') { // station logos come through the hub too (they are often plain http, and fetching them directly would tell their servers who is listening)
const res2 = await open(u.searchParams.get('u') || '', { timeout: 8000 }), type = String(res2.headers['content-type'] || '').split(';')[0].toLowerCase();
@@ -124,4 +135,4 @@ async function handle(req, res) {
} catch (e) { if (res.headersSent) return res.destroy(); return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); }
}
module.exports = { handle, icyFilter, isPrivate };
module.exports = { handle, icyFilter, isPrivate, easternEpoch };
+5 -2
View File
@@ -38,6 +38,7 @@ const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (M
const transcripts = require('./transcripts'); // words for podcasts and audiobooks: the show's own transcript, or speech-to-text (TRANSCRIBE_API_URL)
const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL)
const abapi = require('./abapi'); // audiobook player library, audio and progress (AUDIOBOOKS_URL)
const bible = require('./bible'); // Bible: each person's place, highlights and notes (the text is a static file)
const savefiles = require('./savefiles'); // "Save to Files": copy books, lessons and audiobooks into the person's Seafile
const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies)
const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM)
@@ -547,10 +548,11 @@ const ACCESS = [
[/^\/api\/podcast\//, ['podcast']],
[/^\/api\/vbv\//, ['podcast']],
[/^\/api\/save\//, ['files']],
[/^\/api\/bible\//, ['bible']],
[/^\/api\/read\//, ['read']],
[/^\/api\/radio\//, ['radio']],
[/^\/api\/signal\//, ['signal']],
[/^\/api\/lyrics$/, ['music']],
[/^\/api\/lyrics$/, ['music', 'radio']],
[/^\/api\/transcript$/, ['podcast', 'audio']],
[/^\/api\/mail\//, ['mail']],
[/^\/api\/cal\//, ['calendar']],
@@ -609,7 +611,7 @@ async function handleRequest(req, res) {
const c = t => users.can(user, t);
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: (c('music') || c('radio')) && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
}
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
@@ -645,6 +647,7 @@ async function handleRequest(req, res) {
if (pathname.startsWith('/api/radio/')) return radio.handle(req, res);
if (pathname.startsWith('/api/vbv/')) return vbv.handle(req, res);
if (pathname.startsWith('/api/save/')) return savefiles.handle(req, res);
if (pathname.startsWith('/api/bible/')) return bible.handle(req, res);
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
if (pathname.startsWith('/api/mail/')) return mail.handle(req, res);
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
+1 -1
View File
@@ -9,7 +9,7 @@ const users = require('./users');
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
// Only these keys are accepted. (The music queue stays in the browser.)
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins']);
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']);
const SECRET = new Set(['ab_creds', 'sf_token']);
const MAX_VALUE = 20000; // bytes of JSON per key
+25
View File
@@ -0,0 +1,25 @@
// Bible: where you are reading, and your highlights and notes, are kept per person; the newest edit of each wins and two devices can add without losing each other's.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-bible-'));
const bible = require('../bible'), users = require('../users');
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['bible'], handle: 'Yg' }) };
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => bible.handle(req, res))).listen(0, async () => {
const base = 'http://127.0.0.1:' + hub.address().port + '/api/bible', j = r => r.json(), post = (p, o, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: h });
try {
assert.deepEqual(await j(await fetch(base + '/state')), { pos: null, marks: [] });
await post('/pos', { b: 'JHN', c: 3, v: 16, t: 100 }); let r = await j(await post('/pos', { b: 'GEN', c: 1, t: 50 })); assert.equal(r.b, 'JHN', 'the newest place wins');
assert.deepEqual((await j(await post('/pos', { b: '../x', c: 1, t: 999 }))).b, 'JHN', 'a bad book is ignored'); assert.equal((await j(await fetch(base + '/state'))).pos.c, 3);
r = await j(await post('/marks', [{ id: 'm1', t: 10, type: 'hl', b: 'JHN', c: 3, v: 16, color: 'green', text: 'For God so loved the world' }])); assert.equal(r.length, 1);
r = await j(await post('/marks', [{ id: 'm2', t: 11, type: 'note', b: 'PSA', c: 23, v: 1, ve: 3, note: 'my shepherd' }, { id: 'm1', t: 5, type: 'hl', b: 'JHN', c: 3, v: 16, text: 'stale edit' }, { id: '../bad', t: 1, b: 'JHN' }, { id: 'm3', t: 1, b: 'nope' }]));
assert.deepEqual(r.map(m => m.id).sort(), ['m1', 'm2'], 'two devices add without losing each other'); assert.equal(r.find(m => m.id === 'm1').text, 'For God so loved the world', 'an older edit does not win'); assert.equal(r.find(m => m.id === 'm2').ve, 3);
r = await j(await post('/marks', [{ id: 'm1', t: 20, b: 'JHN', c: 3, v: 16, del: true }])); assert.deepEqual(r.map(m => m.id), ['m2'], 'deleted marks stay deleted');
assert.deepEqual(await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } })), { pos: null, marks: [] }, "each person's place and marks are their own");
assert.equal((await fetch(base + '/nope')).status, 404);
// the bundled text
const d = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8')); assert.equal(d.books.length, 66); assert.equal(d.books.reduce((n, b) => n + b.chapters.length, 0), 1189);
const verses = d.books.flatMap(b => b.chapters.flat()).filter(x => x[0] === 'v').length; assert.equal(verses, 31086, 'every verse is there');
const jn = d.books.find(b => b.id === 'JHN').chapters[2].find(x => x[0] === 'v' && x[1] === 16); assert.match(jn[2].join(''), /For God so loved the world/);
console.log('bible test ok');
} catch (err) { console.error(err); process.exitCode = 1; }
hub.close();
});
+34
View File
@@ -0,0 +1,34 @@
// Lyrics without any extra service: LRCLIB (free, no key) is asked for the exact song, then for the closest title; synced lines when it has them, plain words otherwise.
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
const seen = [];
const fake = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'), J = (o, c = 200) => { res.writeHead(c, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); }; seen.push(u.pathname + '?' + u.searchParams.get('track_name'));
const t = u.searchParams.get('track_name'), a = u.searchParams.get('artist_name');
if (u.pathname === '/api/get') {
if (t === 'Synced') return J({ trackName: 'Synced', artistName: a, albumName: 'Alb', duration: 200, syncedLyrics: '[00:10.00]First line\n[00:20.50]Second line', plainLyrics: 'First line\nSecond line' });
if (t === 'Plain') return J({ trackName: 'Plain', artistName: a, plainLyrics: 'Just words\n\nMore words' });
if (t === 'Quiet') return J({ trackName: 'Quiet', artistName: a, instrumental: true });
if (t === 'Long') return J({ trackName: 'Long', artistName: a, duration: 500, plainLyrics: 'words' });
return J({ statusCode: 404, message: 'not found' }, 404);
}
if (u.pathname === '/api/search') return J(t === 'Fuzzy' ? [{ trackName: 'Other', artistName: 'Someone' }, { trackName: 'Fuzzy', artistName: 'The Band', plainLyrics: 'found by search' }] : []);
res.writeHead(404); res.end();
}).listen(0, async () => {
process.env.LRCLIB_URL = 'http://127.0.0.1:' + fake.address().port; delete process.env.LYRICS_LRCLIB;
const lyrics = require('../lyrics'); lyrics.init({ dataDir: fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-lrclib-')), apiUrl: '' });
const srv = http.createServer((q, r) => lyrics.handle(q, r)).listen(0, async () => {
const ask = async (a, t, d = '') => (await fetch(`http://127.0.0.1:${srv.address().port}/api/lyrics?artist=${encodeURIComponent(a)}&title=${encodeURIComponent(t)}${d ? '&duration=' + d : ''}`)).json();
try {
assert.ok(lyrics.enabled(), 'lyrics work with no Lyrica service');
let r = await ask('Band', 'Synced'); assert.equal(r.found, true); assert.equal(r.source, 'LRCLIB'); assert.equal(r.sync, 'line'); assert.deepEqual(r.lines.map(l => [l.t, l.text]), [[10000, 'First line'], [20500, 'Second line']]);
r = await ask('Band', 'Plain'); assert.equal(r.sync, 'none'); assert.equal(r.lines.filter(l => l.text).length, 2, 'plain words are kept, blank lines too');
assert.equal((await ask('Band', 'Quiet')).instrumental, true);
r = await ask('The Band', 'Fuzzy'); assert.equal(r.found, true, 'when the exact song is not there, the search finds the closest title'); assert.equal(r.lines[0].text, 'found by search');
assert.equal((await ask('Band', 'Nothing here')).found, false);
assert.equal((await ask('Band', 'Long', '200')).found, false, 'a much longer or shorter version is not the same recording'); assert.equal((await ask('Band', 'Long')).found, true, 'a live radio song has no length to compare');
const n = seen.length; await ask('Band', 'Synced'); assert.equal(seen.length, n, 'a song is looked up once, then remembered');
console.log('lrclib test ok');
} catch (e) { console.error(e); process.exitCode = 1; }
srv.close(); fake.close();
});
});
+1 -1
View File
@@ -45,7 +45,7 @@ const lrcLib = require('../lyrics');
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ users: [
{ id: 'uadmin1', name: 'Admin', role: 'admin', tabs: [], handle: 'h', epoch: 1, disabled: false, created: 1 },
{ id: 'ufriend1', name: 'Friend', role: 'user', tabs: ['mail'], handle: 'h2', epoch: 1, disabled: false, created: 2 }] }));
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, LYRICS_API_URL: 'http://127.0.0.1:' + fake.address().port, SEAFILE_URL: 'http://127.0.0.1:9' } });
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, LYRICS_LRCLIB: '0', LYRICS_API_URL: 'http://127.0.0.1:' + fake.address().port, SEAFILE_URL: 'http://127.0.0.1:9' } });
child.stdout.on('data', () => {}); child.stderr.on('data', () => {}); await wait(1500);
const base = 'http://127.0.0.1:' + port, exp = Date.now() + 864e5;
const ck = uid => `hub_session=${uid}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${uid}.1.${exp}`).digest('base64url')}`;
+36
View File
@@ -0,0 +1,36 @@
# Builds public/vendor/bible/bsb.json, the Berean Standard Bible (public domain: https://berean.bible/), from the free Hello AO Bible API
# (https://bible.helloao.org). Run once (needs a network): python3 tools/make-bsb.py
# Compact format: {books:[{id,name,chapters:[[block,...]]}]}; a block is ["h",heading] | ["s",subtitle] | ["p"] (new paragraph) |
# ["v",number,[part,...],[note,...]] where a part is prose text, [level,text] for a poetry line (level 1 or 2), or "\n" for a line break inside a verse.
import json, urllib.request, concurrent.futures as cf, os, sys
API = 'https://bible.helloao.org/api/BSB'
def get(p):
for t in range(4):
try: return json.load(urllib.request.urlopen(urllib.request.Request(API + p, headers={'User-Agent': 'NE-Hub/1.0'}), timeout=40))
except Exception as e: err = e
raise err
books = get('/books.json')['books']
jobs = [(b['id'], c) for b in books for c in range(1, b['numberOfChapters'] + 1)]
with cf.ThreadPoolExecutor(8) as ex: raw = dict(zip(jobs, ex.map(lambda j: get(f'/{j[0]}/{j[1]}.json'), jobs)))
def txt(c): return ' '.join(p if isinstance(p, str) else p.get('text', '') for p in c).replace(' ', ' ').strip()
def chapter(d):
notes = {n['noteId']: n for n in d['chapter'].get('footnotes', [])}; out = []
for x in d['chapter']['content']:
t = x['type']
if t == 'heading': out.append(['h', txt(x['content'])])
elif t == 'hebrew_subtitle': out.append(['s', txt(x['content'])])
elif t == 'line_break': out.append(['p'])
elif t == 'verse':
parts, ns = [], []
for p in x['content']:
if isinstance(p, str): parts.append(p)
elif 'noteId' in p and p['noteId'] in notes: ns.append(notes[p['noteId']]['text']); parts.append(len(ns))
elif 'lineBreak' in p: parts.append('\n')
elif 'poem' in p: parts.append([p['poem'], p['text']])
elif 'text' in p: parts.append(p['text'])
out.append(['v', x['number'], parts] + ([ns] if ns else []))
return out
bible = {'id': 'BSB', 'name': 'Berean Standard Bible', 'license': 'Public domain (dedicated by Bible Hub / Berean Bible, April 30, 2023)', 'source': 'https://berean.bible/', 'books': [
{'id': b['id'], 'name': b['commonName'], 'chapters': [chapter(raw[(b['id'], c)]) for c in range(1, b['numberOfChapters'] + 1)]} for b in books]}
dest = os.path.join(os.path.dirname(__file__), '..', 'public', 'vendor', 'bible', 'bsb.json'); os.makedirs(os.path.dirname(dest), exist_ok=True)
json.dump(bible, open(dest, 'w'), ensure_ascii=False, separators=(',', ':')); print('wrote', dest, os.path.getsize(dest), 'bytes;', sum(len(b['chapters']) for b in bible['books']), 'chapters')
+4 -3
View File
@@ -2,7 +2,7 @@
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-radio-'));
process.env.RADIO_ALLOW_PRIVATE = '1'; // the fake servers below are on this machine
const AUDIO = Buffer.alloc(4096, 0x55), META = Buffer.from("StreamTitle='Great Song - Some Band';"); let port;
const AUDIO = Buffer.alloc(4096, 0x55), META = Buffer.from("StreamTitle='Some Band - Great Song';"); let port;
const fake = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'), p = u.pathname, J = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
if (p === '/json/stations/search') return J([
@@ -32,11 +32,12 @@ const fake = http.createServer((req, res) => {
r = await call('/play/journeyfm'); assert.equal(r.status, 200); assert.equal(r.headers.get('content-type'), 'audio/mpeg'); let body = Buffer.from(await r.arrayBuffer());
assert.equal(body.length, 3072, 'the song-title blocks are taken out of the audio'); assert.ok(body.equals(AUDIO.subarray(0, 3072)), 'and the audio is untouched');
await new Promise(ok => setTimeout(ok, 100)); r = await j(await call('/now/aaaaaaaa-1111')); assert.equal(r.title, '', 'nothing yet for a station not played');
r = await call('/play/aaaaaaaa-1111'); assert.equal(r.status, 200); await r.arrayBuffer(); r = await j(await call('/now/aaaaaaaa-1111')); assert.equal(r.title, 'Great Song - Some Band', 'the title carried in the stream is reported');
r = await call('/play/aaaaaaaa-1111'); assert.equal(r.status, 200); await r.arrayBuffer(); r = await j(await call('/now/aaaaaaaa-1111')); assert.equal(r.title, 'Great Song', 'the title carried in the stream is reported'); assert.equal(r.artist, 'Some Band', '"Artist - Title" is split into the artist and the title'); assert.equal(r.startedAt, 0, 'with no clock from the station there is no start time');
r = await call('/play/eeeeeeee-5555'); assert.equal(r.status, 502, 'a playlist file is not a stream');
assert.equal((await call('/play/..%2Fx')).status, 404); assert.equal((await call('/play/zzzzzzzz-9999')).status, 502, 'an unknown station is an error, not a crash');
// The Journey's own feed
r = await j(await call('/now/journeyfm')); assert.equal(r.title, "A Lil' Church (Nobody's Too Lost)"); assert.equal(r.artist, 'Tobymac'); assert.equal(r.recent.length, 2, "the station's own sound bites (no artist) are not listed songs");
r = await j(await call('/now/journeyfm')); assert.equal(r.title, "A Lil' Church (Nobody's Too Lost)"); assert.equal(r.artist, 'Tobymac'); assert.equal(new Date(r.startedAt).toISOString(), '2026-10-25T02:01:37.000Z', "the station's clock (US Eastern) becomes a real moment in time"); assert.equal(r.recent.length, 2, "the station's own sound bites (no artist) are not listed songs");
const E = radio.easternEpoch; assert.equal(new Date(E('01/15/2026 08:00:00')).toISOString(), '2026-01-15T13:00:00.000Z', 'winter: Eastern time is 5 hours behind'); assert.equal(new Date(E('07/04/2026 12:00:00')).toISOString(), '2026-07-04T16:00:00.000Z', 'summer: 4 hours behind'); assert.equal(new Date(E('03/08/2026 03:30:00')).toISOString(), '2026-03-08T07:30:00.000Z', 'just after the clocks go forward'); assert.equal(E('nonsense'), 0);
// saved stations
assert.equal((await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'aaaaaaaa-1111' }) })).status, 200); r = await j(await call('/stations')); assert.deepEqual(r.mine.map(s => s.name), ['Good FM']);
await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'aaaaaaaa-1111' }) }); assert.equal((await j(await call('/stations'))).mine.length, 1, 'saving twice keeps one');
+2 -2
View File
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
fs.mkdirSync(DATA_DIR, { recursive: true });
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
const ALWAYS = ['home', 'settings'];
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
let cache = null;
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };