Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36cbdcf200 | ||
|
|
67e8f7ed5d | ||
|
|
661a15162c | ||
|
|
2d975baea2 | ||
|
|
424d797ecf | ||
|
|
387736fb56 | ||
|
|
1e46aad9ea | ||
|
|
7fee97497d | ||
|
|
026e74641e | ||
|
|
239730fd3b | ||
|
|
047ac32b19 | ||
|
|
60291c0e21 |
No files matched your search
@@ -59,11 +59,14 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
- **Books**: an e-book reader for **EPUB, PDF, DOCX, MOBI, AZW and AZW3** (Media group; give it to people in Settings -> People as *Books*). Add files with the button or by dropping them on the page (up to 300 MB each; the server checks a file really is what its name says and does not store the same book twice). Titles, authors and covers are read out of the books in the browser; long-press or right-click a book to edit, download or delete it. Each person has their own shelf in `/data/users/<id>/books/`.
|
||||
- **Reading**: EPUB uses epub.js (pages or scroll, two-page spreads on wide screens), PDF uses pdf.js (continuous scroll, zoom, selectable text, dark and sepia tints) DOCX is converted to HTML by mammoth, and MOBI, AZW and AZW3 are read by `@lingo-reader/mobi-parser`; both are shown in a frame that cannot run scripts (scroll only, with contents from the book or its headings). Contents, search inside the book, a progress slider, tap the edges or use the arrow keys, swipe on touch screens, tap the middle to hide the bars. Appearance (theme, text size, font, line spacing, margins, layout, zoom) is remembered on each device.
|
||||
- **Synced**: where you got to, bookmarks, highlights (four colours) and notes are kept on the server, so a book opens where you left off on any device; two devices adding notes at once keep both (the newest edit of the same note wins). Continue reading on the library page, sorting and search, and *Copy as text* exports a book's notes.
|
||||
- **Book store** (Books -> Book store): search free, DRM-free books from **Project Gutenberg** (through Gutendex), **Standard Ebooks**, 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: `GUTENDEX_URL`, `STANDARD_EBOOKS_URL` and `ARCHIVE_URL` can point at mirrors. `node tools/store-test.js` tests it against fake catalogues; it has not been run against the live sites from the build environment, so if one of them changes its feed format, the line under the search box names the source that failed.
|
||||
- **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. **Cover art**: the cover of the song that is on (from Apple's free iTunes search, no key; the song's artist and title are sent to it), else the station's logo, else a coloured tile with the station's initials, so there is never an empty picture. A live station shows *LIVE* in place of a length, and has no seek bar, queue or skip buttons. **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
|
||||
@@ -97,6 +100,12 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
- Message text is shown in a sandboxed frame with no scripts, and pictures from the internet stay hidden until you press Show pictures. Not included: a combined inbox across accounts (switch account at the top), message previews in the list for Gmail/iCloud, drafts autosave, contacts, Microsoft 365 work accounts, other IMAP providers (easy to add the same way).
|
||||
**One-time Outlook.com setup** (step by step, with troubleshooting, in [docs/mail-setup.md](docs/mail-setup.md); needs a free Microsoft account and takes five minutes): go to entra.microsoft.com -> App registrations -> New registration. Name it NE Hub; supported account types **Personal Microsoft accounts only**; Redirect URI: platform **Web**, value `<your hub address>/api/mail/callback` (for example `https://hub.necloud.us/api/mail/callback`; it must match `PUBLIC_URL` exactly). Then API permissions -> Add -> Microsoft Graph -> Delegated: `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Copy the **Application (client) ID** and paste it, with the secret, into **Settings -> Microsoft app (admin)** (or set `MS_CLIENT_ID` / `MS_CLIENT_SECRET` in the environment, which win). If you registered it as a Web app, also make a client secret (Certificates & secrets) and put its value in `MS_CLIENT_SECRET`; if you used platform "Mobile and desktop applications" instead, no secret is needed. Leave `MS_CLIENT_ID` empty to switch Mail off. (The old bundled Cypht webmail and its `nehub-cypht` volume are gone; you can delete that volume.) Connecting from the installed iPhone/iPad app may open Microsoft's page in Safari; if the app does not notice, reopen it.
|
||||
|
||||
## Visualizer
|
||||
|
||||
Tap **Visuals** on the Now Playing sheet, the Music card or the Journey FM card for a full-screen picture that moves with the sound: Classic (the swirling, colour-cycling look iTunes had, and the default for music), Rings (cover art in the middle), Bars, Waves, Aurora and Sparks. It works for music, radio, podcasts and audiobooks, picks a calm look for speech, and takes its colours from the cover. Tap the picture to hide the buttons; Esc closes it. The screen stays awake while it is open.
|
||||
|
||||
It listens to the sound the browser is playing. Where it cannot (iPhone Safari, or audio that comes from another site such as Verse By Verse and LibriVox), it falls back to an "ambient" animation that still keeps time with play and pause, and the top of the screen says which mode is on.
|
||||
|
||||
## Install on iPhone / iPad (PWA)
|
||||
|
||||
Open the site in **Safari** (it must be the `https://` address), tap **Share -> Add to Home Screen**. The home-screen app has proper
|
||||
|
||||
@@ -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 };
|
||||
@@ -144,4 +144,7 @@ async function handle(req, res) {
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
}
|
||||
|
||||
module.exports = { handle, mergeNotes, looksLike, addBook, index, TYPES };
|
||||
/** Where a book's file is on disk (for the signed-in person). */
|
||||
const fileOf = id => { const b = index().find(x => x.id === id); return b ? { path: path.join(dir(), id + '.' + b.ext), ext: b.ext, title: b.title, author: b.author } : null; };
|
||||
|
||||
module.exports = { handle, mergeNotes, looksLike, addBook, index, TYPES, fileOf };
|
||||
+1
-1
@@ -70,4 +70,4 @@ async function handle(req, res) {
|
||||
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); }
|
||||
}
|
||||
|
||||
module.exports = { handle };
|
||||
module.exports = { handle, book, allowed };
|
||||
@@ -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 () => {
|
||||
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 });
|
||||
const result = shape(d, seconds);
|
||||
try { atomicWrite(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ }
|
||||
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
@@ -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; 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 visualizer; do node tools/$t-test.js || exit 1; done",
|
||||
"audit": "npm audit --omit=dev --audit-level=high"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
+71
-20
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+4
-1
@@ -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,8 +75,11 @@
|
||||
<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>
|
||||
<script src="/visualizer.js"></script>
|
||||
<script src="/settings.js"></script>
|
||||
<script src="/files.js"></script>
|
||||
<script src="/files-extra.js"></script>
|
||||
|
||||
@@ -84,10 +84,11 @@ async function lvSheet(id, after, known = new Map()) {
|
||||
el.querySelector('.empty').outerHTML = `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>
|
||||
<div style="min-width:0"><p class="sub" style="margin:0">${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' · '))}</p><p class="sub" style="margin:4px 0 0">${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}</p></div></div>
|
||||
${b.description ? `<p style="margin:10px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden">${esc(b.description)}</p>` : ''}
|
||||
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button><button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button></div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button></div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||
const go = at => { if (!b.chapters.length) return toast('This book has no audio'); lvPlay(b, at); closeSheet(); if (!onShelf()) lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); if (after) after(); };
|
||||
el.querySelector('#lvp').onclick = () => go();
|
||||
if (el.querySelector('#lvs')) el.querySelector('#lvs').onclick = () => go(0);
|
||||
if (el.querySelector('#lvf')) el.querySelector('#lvf').onclick = () => { closeSheet(); saveToFiles(b.title, { kind: 'librivox-book', id }); };
|
||||
el.querySelector('#lvc').onclick = () => { const offs = lvOffsets(b); el.querySelector('#lvch').innerHTML = b.chapters.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${esc([abFmt(c.secs), c.reader].filter(Boolean).join(' · '))}</small></span></button>`).join(''); el.querySelectorAll('#lvch [data-i]').forEach(x => x.onclick = () => go(offs[x.dataset.i])); };
|
||||
el.querySelector('#lvk').onclick = async e => { const btn = e.currentTarget; try { if (onShelf()) { await lvApi('/shelf/' + id, lvOpt('DELETE')); lvShelf = lvShelf.filter(x => x.id !== id); } else { await lvApi('/shelf', lvOpt('POST', { id })); lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); } btn.textContent = onShelf() ? 'Remove from shelf' : 'Save to shelf'; if (after) after(); } catch (x) { toast(x.message); } };
|
||||
}
|
||||
+22
-13
@@ -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
@@ -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' });
|
||||
|
||||
+12
-9
@@ -62,6 +62,7 @@ function loadCur(delay = 220) {
|
||||
if (delay) pl.a.pause();
|
||||
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
|
||||
if (lyr.open) { lyrShell(); lyrReload(); }
|
||||
if (typeof vizTrackChanged === 'function') vizTrackChanged();
|
||||
const go = () => {
|
||||
if (tok !== loadTok) return;
|
||||
const hit = tc.m.get(t.src);
|
||||
@@ -214,20 +215,22 @@ function modeUI() {
|
||||
document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
|
||||
}
|
||||
function syncUI() {
|
||||
const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
|
||||
const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
|
||||
const state = pl.buf && !a.paused ? 'buf' : String(a.paused), ic = state === 'buf' ? icon('spin') : icon(a.paused ? 'play' : 'pause');
|
||||
const mt = $('#mt'); if (mt && mt.dataset.s !== state) { mt.innerHTML = ic; mt.dataset.s = state; }
|
||||
const bf = $('#mbuf'); if (bf) { let e = 0; try { for (let i = 0; i < a.buffered.length; i++) if (a.buffered.start(i) <= a.currentTime + 1) e = Math.max(e, a.buffered.end(i)); } catch {} bf.style.width = d ? Math.min(100, (e / d) * 100) + '%' : '0'; }
|
||||
const tm = $('#mtm'); if (tm) { const v = fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
|
||||
const tm = $('#mtm'); if (tm) { const v = liveT ? 'LIVE' : fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
|
||||
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
|
||||
const t = cur(), sub = t ? (pl.buf ? (navigator.onLine ? 'Buffering…' : 'Waiting for connection…') : t.artist) : '';
|
||||
const ms = $('#mo small'); if (ms && ms.textContent !== sub) ms.textContent = sub;
|
||||
const ns = $('#npsub'); if (ns && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (ns.textContent !== v) ns.textContent = v; }
|
||||
const nt = $('#npt'); if (nt && nt.dataset.s !== state) { nt.innerHTML = ic; nt.dataset.s = state; }
|
||||
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
|
||||
const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
|
||||
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
|
||||
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = liveT ? 0 : a.currentTime; } if (sk) sk.hidden = liveT;
|
||||
const c = $('#npc'); if (c) c.textContent = liveT ? '\u25CF LIVE' : fmtDur(a.currentTime * 1000);
|
||||
const dd = $('#npd'); if (dd) dd.textContent = liveT ? '' : fmtDur(d * 1000);
|
||||
for (const id of ['nps', 'npr', 'npq', 'npp', 'npn', 'mpv', 'mn', 'mseek']) { const e = $('#' + id); if (e) e.hidden = liveT; } // (a live station has no queue, no skipping and no seeking)
|
||||
const lt = $('#lyrt'); if (lt && lt.dataset.s !== state) { lt.innerHTML = ic; lt.dataset.s = state; }
|
||||
const vt = $('#vizt'); if (vt && vt.dataset.s !== state) { vt.innerHTML = ic; vt.dataset.s = state; }
|
||||
// the Now Playing card on the Music tab
|
||||
const ct = $('#cnt'); if (ct && ct.dataset.s !== state) { ct.innerHTML = ic; ct.dataset.s = state; }
|
||||
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
|
||||
@@ -247,11 +250,11 @@ function renderNpCard() {
|
||||
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
|
||||
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
|
||||
<button class="ibtn big" id="cnt" aria-label="Play or pause"></button><button class="ibtn" id="cnn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="cnr" aria-label="Repeat">${icon('repeat')}</button>
|
||||
<button class="btn ghost npc-l" id="cnl">${lyrLabel()}</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
|
||||
<button class="btn ghost npc-l" id="cnl">${lyrLabel()}</button><button class="btn ghost npc-l" id="cnv">Visuals</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
|
||||
const sk = el.querySelector('#cnk');
|
||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#cnc').textContent = fmtDur(sk.value * 1000); };
|
||||
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen;
|
||||
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen; el.querySelector('#cnv').onclick = vizOpen;
|
||||
el.querySelector('#cnp').onclick = prev; el.querySelector('#cnn').onclick = next; el.querySelector('#cnt').onclick = toggle;
|
||||
el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
modeUI(); syncUI();
|
||||
@@ -271,11 +274,11 @@ function openNow() {
|
||||
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
|
||||
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button><button class="btn ghost" id="npv">Visuals</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
||||
const sk = el.querySelector('#npk');
|
||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
||||
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
|
||||
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
|
||||
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
||||
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
|
||||
|
||||
+23
-6
@@ -3,8 +3,9 @@
|
||||
// out of it), so stations that only offer plain http still play, and everything plays in the same player as Music.
|
||||
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 rdoArt = (s, song) => '/api/radio/art?s=' + encodeURIComponent(s.id) + (song && song.title ? '&artist=' + encodeURIComponent(song.artist || '') + '&title=' + encodeURIComponent(song.title) : '');
|
||||
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: rdoArt(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 +17,25 @@ 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;
|
||||
t.thumb = rdoArt({ id: t.key.slice(6) }, t.song); // the cover of the song that is on, else the station's logo
|
||||
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(); }
|
||||
if (typeof vizTrackChanged === 'function' && was !== now) vizTrackChanged();
|
||||
}
|
||||
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 +51,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><button class="btn ghost" id="rdojv">Visuals</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 +68,8 @@ 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); };
|
||||
$('#rdojv').onclick = () => { if (!rdoPlaying() || rdoPlaying().key !== 'radio:' + j.id) playStation(j); setTimeout(() => { if (typeof vizOpen === 'function') vizOpen(); }, 400); };
|
||||
$('#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); };
|
||||
|
||||
@@ -82,6 +82,7 @@ function rdBookMenu(b) {
|
||||
actionSheet(b.title, b.author || b.ext.toUpperCase(), [
|
||||
{ icon: 'book', label: 'Open', run: () => { location.hash = '#/read/open/' + b.id; } },
|
||||
{ icon: 'edit', label: 'Edit title and author', run: () => rdEditBook(b) },
|
||||
...(allowed('files') ? [{ icon: 'files', label: 'Save to Files', run: () => saveToFiles(b.title, { kind: 'book', id: b.id }) }] : []),
|
||||
{ icon: 'dl', label: 'Download', run: () => { const a = document.createElement('a'); a.href = `/api/read/book/${b.id}/file`; a.download = b.title + '.' + b.ext; document.body.appendChild(a); a.click(); a.remove(); } },
|
||||
{ icon: 'trash', label: 'Delete from library', danger: true, run: () => confirmSheet('Delete this book?', `"${b.title}" and your bookmarks, notes and highlights in it are removed.`, 'Delete', async () => {
|
||||
try { await rdApi('/book/' + b.id, { method: 'DELETE' }); toast('Deleted'); route(); } catch (e) { toast(e.message); } }) },
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
// ---- "Save to Files": copy a book, free audiobook or Verse By Verse lesson into your Seafile. You pick the library and folder; the hub does the downloading and
|
||||
// uploading (a whole study keeps going in the background), skips what is already there, and tells you when it is done.
|
||||
const sfLoc = () => { try { return JSON.parse(localStorage.getItem('save_loc:' + (ME ? ME.id : ''))); } catch { return null; } };
|
||||
async function saveToFiles(title, body) {
|
||||
if (!allowed('files')) return toast('Your account does not have Files');
|
||||
if (!sf.token()) return toast('Open Files and sign in first', { label: 'Files', run: () => { location.hash = '#/files'; } });
|
||||
const dst = await chooseFolder('Save “' + title + '” in…', sfLoc(), { newFolder: true }); if (!dst) return;
|
||||
try { localStorage.setItem('save_loc:' + (ME ? ME.id : ''), JSON.stringify(dst)); } catch {}
|
||||
let job; try { job = await jfetch('/api/save/', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...body, repo: dst.repo, dir: dst.dir }) }); } catch (e) { return toast(e.message); }
|
||||
sfProgress(job, dst);
|
||||
}
|
||||
function sfProgress(job, dst) {
|
||||
let hidden = false, el = null;
|
||||
const where = () => (dst.repoName || 'Files') + (dst.dir === '/' ? '' : dst.dir) + '/' + job.where;
|
||||
const open = () => { location.hash = '#/files/' + encodeURIComponent(dst.repo) + (dst.dir + '/' + job.where).split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); };
|
||||
const draw = () => {
|
||||
if (!el || !document.body.contains(el)) return; const n = job.done + job.skipped + job.missing + job.failed, pct = job.total ? Math.round(n / job.total * 100) : 100;
|
||||
el.querySelector('#sfb').innerHTML = `<p class="sub" style="margin:0 0 8px;word-break:break-word">${esc(where())}</p><div style="height:8px;border-radius:99px;background:var(--panel2);overflow:hidden"><div style="height:100%;width:${pct}%;background:var(--accent);transition:width .3s"></div></div>
|
||||
<p style="margin:10px 0 0">${job.state === 'running' ? `Saving ${Math.min(n + 1, job.total)} of ${job.total}…` : job.state === 'done' ? 'Done' : job.state === 'cancelled' ? 'Stopped' : 'Could not finish'}</p>${job.current && job.state === 'running' ? `<p class="sub" style="margin:2px 0 0;word-break:break-word">${esc(job.current)}</p>` : ''}
|
||||
<p class="sub" style="margin:6px 0 0">${esc(sfSummary(job))}</p>${job.error ? `<div class="err">${esc(job.error)}</div>` : ''}${job.errors.length && !job.error ? `<div class="err" style="font-size:13px">${job.errors.map(esc).join('<br>')}</div>` : ''}`;
|
||||
const b = el.querySelector('#sfx'); b.textContent = job.state === 'running' ? 'Stop' : 'Close'; el.querySelector('#sfh').hidden = job.state !== 'running'; el.querySelector('#sfo').hidden = job.state === 'running' || !job.done && !job.skipped;
|
||||
};
|
||||
el = openSheet('Saving to Files', job.label, '<div id="sfb"></div><div class="bar"><button class="btn ghost" id="sfx" style="flex:1;justify-content:center">Stop</button><button class="btn ghost" id="sfh" style="flex:1;justify-content:center">Hide</button><button class="btn" id="sfo" hidden style="flex:1;justify-content:center">Open folder</button></div>');
|
||||
el.querySelector('#sfx').onclick = async () => { if (job.state === 'running') { try { await fetch('/api/save/job/' + job.id, { method: 'DELETE' }); } catch {} } else closeSheet(); };
|
||||
el.querySelector('#sfh').onclick = () => { hidden = true; closeSheet(); toast('Saving in the background'); };
|
||||
el.querySelector('#sfo').onclick = () => { closeSheet(); open(); };
|
||||
draw();
|
||||
const tick = async () => {
|
||||
try { job = await jfetch('/api/save/job/' + job.id); } catch (e) { if (e.status === 404) return; setTimeout(tick, 4000); return; }
|
||||
draw();
|
||||
if (job.state === 'running') return setTimeout(tick, 1200);
|
||||
if (hidden || !document.body.contains(el)) toast(job.state === 'done' ? `Saved ${job.done} file${job.done === 1 ? '' : 's'} to Files` + (job.failed ? `, ${job.failed} failed` : '') : job.error || 'Saving stopped', job.done || job.skipped ? { label: 'Open', run: open } : undefined);
|
||||
};
|
||||
setTimeout(tick, 800);
|
||||
}
|
||||
const sfSummary = j => [j.done && `${j.done} saved`, j.skipped && `${j.skipped} already there`, j.missing && `${j.missing} with no handout`, j.failed && `${j.failed} failed`].filter(Boolean).join(' · ');
|
||||
@@ -902,3 +902,51 @@ 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); }
|
||||
|
||||
/* Music visualizer: a full-screen picture that moves with the sound */
|
||||
#viz { position: fixed; inset: 0; z-index: 40; background: #000; color: #fff; display: flex; flex-direction: column; justify-content: space-between; touch-action: manipulation; user-select: none; }
|
||||
body.vizopen { overflow: hidden; }
|
||||
#viz canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#viz > .viztop, #viz > .vizfoot { position: relative; z-index: 1; display: flex; align-items: center; gap: 10px; padding: calc(var(--sat, 0px) + 12px) 16px 12px; transition: opacity .4s; background: linear-gradient(rgba(0,0,0,.55), transparent); }
|
||||
#viz > .vizfoot { justify-content: center; gap: 22px; padding: 12px 16px calc(var(--sab, 0px) + 20px); background: linear-gradient(transparent, rgba(0,0,0,.55)); }
|
||||
#viz .viztop .grow { min-width: 0; } #viz .viztop b, #viz .viztop small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-shadow: 0 1px 4px rgba(0,0,0,.6); }
|
||||
#viz .viztop small { opacity: .75; font-size: 12px; }
|
||||
#viz .btn, #viz .ibtn { color: #fff; border-color: rgba(255,255,255,.35); background: rgba(0,0,0,.35); }
|
||||
#viz.quiet > .viztop, #viz.quiet > .vizfoot { opacity: 0; pointer-events: none; }
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v42';
|
||||
const CACHE = 'hub-v48';
|
||||
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'];
|
||||
|
||||
|
||||
+10
-2
@@ -49,12 +49,12 @@ async function vbStudy(slug) {
|
||||
const nextUp = () => partial || s.lessons.find(l => !vbDone(key(l))) || s.lessons[0];
|
||||
const row = l => { const p = vbLeft(key(l)), done = vbDone(key(l)); return `<div class="row vbrow" data-n="${l.n}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play lesson ${l.n + 1}">${icon('play')}</button>
|
||||
<span class="grow"><b${done ? ' style="color:var(--muted)"' : ''}>${l.n + 1}. ${esc(l.title)}</b><small>${esc([vbDate(l.date), done ? 'played' : p ? Math.max(1, Math.round((p.dur - p.pos) / 60)) + ' min left' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||
<a class="btn ghost" href="/api/vbv/notes/${esc(slug)}/${l.n}" target="_blank" rel="noopener" style="min-height:36px;padding:0 12px" title="Lesson handout (PDF)">Notes</a></div>`; };
|
||||
<a class="btn ghost" href="/api/vbv/notes/${esc(slug)}/${l.n}" target="_blank" rel="noopener" style="min-height:36px;padding:0 12px" title="Lesson handout (PDF)">Notes</a>${allowed('files') ? `<button class="ibtn" data-save aria-label="Save to Files" title="Save to Files">${icon('dl')}</button>` : ''}</div>`; };
|
||||
const resume = nextUp(), rp = vbLeft(key(resume));
|
||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast/vbv">${icon('back')}Studies</a></div>
|
||||
<div style="display:flex;gap:16px;align-items:flex-start"><span style="width:120px;flex:none">${vbCover(s)}</span><div style="min-width:0"><h1 style="margin:0">${esc(s.title)}</h1><p class="sub" style="margin:2px 0 0">${esc([s.teacher, s.lessons.length + ' lessons'].filter(Boolean).join(' · '))}</p>
|
||||
<p class="sub" style="margin:6px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden">${esc(s.description)}</p></div></div>
|
||||
<div class="bar"><button class="btn" id="vbgo">${icon('play')}${started ? 'Continue' : 'Start'}${resume.n ? ' · lesson ' + (resume.n + 1) : ''}</button></div><h2>Lessons</h2><div class="list">${s.lessons.map(row).join('')}</div>
|
||||
<div class="bar"><button class="btn" id="vbgo">${icon('play')}${started ? 'Continue' : 'Start'}${resume.n ? ' · lesson ' + (resume.n + 1) : ''}</button>${allowed('files') ? `<button class="btn ghost" id="vbsave">${icon('dl')}Save to Files</button>` : ''}</div><h2>Lessons</h2><div class="list">${s.lessons.map(row).join('')}</div>
|
||||
<p class="sub" style="margin-top:14px">Teaching and handouts © Verse By Verse Ministry International, shared freely at <a href="https://versebyverseministry.org/bible-studies/${esc(slug)}" target="_blank" rel="noopener" style="color:var(--accent)">versebyverseministry.org</a>.</p></div>`;
|
||||
const play = l => { // this lesson (from where you left off in it), then the rest of the study in order
|
||||
const p = vbLeft(key(l)), np = vbPlaying(); if (np && np.key === key(l)) { if (pl.a.paused) pl.a.play().catch(() => {}); return; }
|
||||
@@ -62,6 +62,14 @@ async function vbStudy(slug) {
|
||||
playQueue(q, 0); if (p) pl.a.addEventListener('loadedmetadata', () => { const t = vbPlaying(); if (t && t.key === key(l)) pl.a.currentTime = p.pos; }, { once: true });
|
||||
};
|
||||
$('#vbgo').onclick = () => play(resume);
|
||||
if ($('#vbsave')) $('#vbsave').onclick = () => actionSheet('Save “' + s.title + '” to Files', s.lessons.length + ' lessons. Whatever is already in the folder is skipped.', [
|
||||
{ label: 'Lessons and handouts', icon: 'dl', run: () => saveToFiles(s.title, { kind: 'vbv-study', slug, what: 'both' }) },
|
||||
{ label: 'Lesson audio only', icon: 'audio', run: () => saveToFiles(s.title, { kind: 'vbv-study', slug, what: 'audio' }) },
|
||||
{ label: 'Handouts only', icon: 'doc', run: () => saveToFiles(s.title, { kind: 'vbv-study', slug, what: 'notes' }) }]);
|
||||
document.querySelectorAll('.vbrow').forEach(r => { const l = s.lessons[+r.dataset.n]; r.querySelector('[data-play]').onclick = () => play(l);
|
||||
const sv = r.querySelector('[data-save]'); if (sv) sv.onclick = () => actionSheet(s.title + ', lesson ' + (l.n + 1), l.title, [
|
||||
{ label: 'Lesson and handout', icon: 'dl', run: () => saveToFiles(s.title + ' ' + (l.n + 1), { kind: 'vbv-lesson', slug, n: l.n, what: 'both' }) },
|
||||
{ label: 'Lesson audio only', icon: 'audio', run: () => saveToFiles(s.title + ' ' + (l.n + 1), { kind: 'vbv-lesson', slug, n: l.n, what: 'audio' }) },
|
||||
{ label: 'Handout only', icon: 'doc', run: () => saveToFiles(s.title + ' ' + (l.n + 1), { kind: 'vbv-notes', slug, n: l.n }) }]);
|
||||
});
|
||||
}
|
||||
Vendored
+1
@@ -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
|
||||
@@ -0,0 +1,192 @@
|
||||
'use strict';
|
||||
// ---- Visualizer: full-screen moving pictures for whatever is playing (music, radio, podcasts, audiobooks), coloured from the cover art. It listens to the real sound
|
||||
// where the browser lets it do so without touching playback (HTMLMediaElement.captureStream: Chrome, Edge, Firefox, Android). Elsewhere (Safari on an iPhone or iPad can
|
||||
// only tap audio in a way that can silence it) and for audio that comes from another website, it draws an "ambient" version that moves like the music or the voice
|
||||
// but is not driven by the sound. The screen says which one you are looking at.
|
||||
const VIZ_MODES = [['classic', 'Classic'], ['rings', 'Rings'], ['bars', 'Bars'], ['wave', 'Waves'], ['aurora', 'Aurora'], ['sparks', 'Sparks']];
|
||||
const VIZ_N = 48;
|
||||
const viz = { open: false, el: null, cv: null, g: null, raf: 0, last: 0, t: 0, mode: 'rings', bands: new Float32Array(VIZ_N), tap: null, tapFailed: false, live: false, silent: 0, hue: 220, pal: ['#6a8cff', '#a06bff', '#4fd1c5'], art: null, soft: null,
|
||||
bass: 0, mid: 0, high: 0, avg: 0, beat: 0, rings: [], parts: [], chromeT: 0, wake: null, level: 0.25, ph: Math.random() * 100 };
|
||||
const vizKind = t => /^(pod|ab|lv|vbv):/.test(String(t && t.key)) ? 'speech' : 'music';
|
||||
const vizPref = k => { try { return localStorage.getItem('viz_mode:' + k); } catch { return null; } };
|
||||
const vizSetPref = (k, m) => { try { localStorage.setItem('viz_mode:' + k, m); } catch {} };
|
||||
const vizClamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));
|
||||
|
||||
// ---- colours from the cover
|
||||
function vizColors(t) {
|
||||
const hashHue = () => { let h = 0; for (const c of String(t.title || t.key)) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
|
||||
const set = h => { viz.hue = h; viz.pal = [`hsl(${h} 78% 62%)`, `hsl(${(h + 55) % 360} 78% 64%)`, `hsl(${(h + 305) % 360} 72% 58%)`]; };
|
||||
set(hashHue()); viz.art = null; viz.soft = null;
|
||||
if (!t.thumb) return;
|
||||
const img = new Image(); img.onload = () => {
|
||||
if (!viz.open) return;
|
||||
try {
|
||||
const c = document.createElement('canvas'); c.width = c.height = 24; const x = c.getContext('2d'); x.drawImage(img, 0, 0, 24, 24); const d = x.getImageData(0, 0, 24, 24).data;
|
||||
let r = 0, g = 0, b = 0, w = 0; for (let i = 0; i < d.length; i += 4) { const mx = Math.max(d[i], d[i + 1], d[i + 2]), mn = Math.min(d[i], d[i + 1], d[i + 2]), sat = (mx - mn) / (mx || 1), k = sat * mx; if (mx > 50 && sat > 0.22) { r += d[i] * k; g += d[i + 1] * k; b += d[i + 2] * k; w += k; } }
|
||||
if (w > 0) { r /= w; g /= w; b /= w; const mx = Math.max(r, g, b), mn = Math.min(r, g, b), df = mx - mn; let h = df === 0 ? 0 : mx === r ? ((g - b) / df) % 6 : mx === g ? (b - r) / df + 2 : (r - g) / df + 4; set(Math.round(((h * 60) + 360) % 360)); }
|
||||
viz.art = img; const s = document.createElement('canvas'); s.width = s.height = 32; s.getContext('2d').drawImage(img, 0, 0, 32, 32); viz.soft = s; // (a tiny copy, stretched later: a cheap blur)
|
||||
} catch { viz.art = img; }
|
||||
};
|
||||
img.src = px.art(t.thumb, 300);
|
||||
}
|
||||
|
||||
// ---- listening to the real sound (without touching playback)
|
||||
function vizTapStart() {
|
||||
if (viz.tap || viz.tapFailed) return;
|
||||
const a = pl.a, cs = a.captureStream || a.mozCaptureStream, AC = window.AudioContext || window.webkitAudioContext;
|
||||
if (!cs || !AC) { viz.tapFailed = true; return; }
|
||||
try {
|
||||
const stream = cs.call(a), ctx = new AC(), an = ctx.createAnalyser(); an.fftSize = 1024; an.smoothingTimeConstant = 0.7;
|
||||
viz.tap = { stream, ctx, an, data: new Uint8Array(an.frequencyBinCount), src: null, track: null }; ctx.resume().catch(() => {});
|
||||
} catch { viz.tapFailed = true; viz.tap = null; }
|
||||
}
|
||||
function vizTapAttach() { // joins whichever audio track the stream has right now (a new song or episode may bring a new one)
|
||||
const k = viz.tap; if (!k) return; const tr = k.stream.getAudioTracks().find(x => x.readyState === 'live'); if (!tr || k.track === tr) return;
|
||||
try { if (k.src) k.src.disconnect(); k.src = k.ctx.createMediaStreamSource(new MediaStream([tr])); k.src.connect(k.an); k.track = tr; } catch {}
|
||||
}
|
||||
function vizTapStop() { const k = viz.tap; viz.tap = null; if (!k) return; try { if (k.src) k.src.disconnect(); k.stream.getTracks().forEach(t => t.stop()); k.ctx.close(); } catch {} }
|
||||
|
||||
// ---- the 48 bands: from the sound, or made up to move like it
|
||||
function vizBands(dt, playing, kind) {
|
||||
const B = viz.bands; let reacted = false;
|
||||
if (viz.tap) {
|
||||
vizTapAttach(); const k = viz.tap; if (k.ctx.state === 'suspended') k.ctx.resume().catch(() => {});
|
||||
k.an.getByteFrequencyData(k.data); let sum = 0; for (let i = 0; i < k.data.length; i++) sum += k.data[i];
|
||||
if (sum > 0) { viz.silent = 0; reacted = true; } else if (playing) viz.silent += dt;
|
||||
if (reacted) {
|
||||
let peak = 0; const out = new Float32Array(VIZ_N);
|
||||
for (let i = 0; i < VIZ_N; i++) { const a = Math.floor(Math.pow(300, i / VIZ_N)) + 1, b = Math.max(a + 1, Math.floor(Math.pow(300, (i + 1) / VIZ_N)) + 1); let m = 0; for (let j = a; j < b && j < k.data.length; j++) m = Math.max(m, k.data[j]); out[i] = (m / 255) * (0.75 + 0.9 * i / VIZ_N); peak = Math.max(peak, out[i]); }
|
||||
viz.level = Math.max(peak, viz.level * (1 - dt * 0.25)); const gain = 0.9 / Math.max(0.35, viz.level); // (quiet recordings still fill the picture)
|
||||
for (let i = 0; i < VIZ_N; i++) { const v = vizClamp(out[i] * gain), s = v > B[i] ? 0.65 : 0.14; B[i] += (v - B[i]) * s; }
|
||||
}
|
||||
}
|
||||
viz.live = reacted || (viz.live && viz.silent < 2.5 && !!viz.tap);
|
||||
if (!viz.live) { // ambient
|
||||
const t = viz.t + viz.ph; let beat, energy;
|
||||
if (kind === 'music') { const p = (t % 0.5) / 0.5, bar = Math.floor(t / 0.5) % 4; beat = Math.exp(-p * 5.5) * (bar === 0 ? 1 : 0.72); energy = 0.55 + 0.25 * Math.sin(t * 0.21) + 0.12 * Math.sin(t * 0.9); }
|
||||
else { beat = vizClamp(0.5 + 0.28 * Math.sin(t * 7.1) + 0.24 * Math.sin(t * 11.7 + 1) + 0.2 * Math.sin(t * 2.9 + 2), 0, 1) * vizClamp(0.7 + 0.5 * Math.sin(t * 0.45), 0.15, 1); energy = 0.34; } // (a voice: soft, irregular, phrase by phrase)
|
||||
for (let i = 0; i < VIZ_N; i++) {
|
||||
const f = i / VIZ_N, base = (kind === 'music' ? 0.18 + 0.5 * (1 - f) : 0.12 + 0.55 * Math.exp(-Math.pow((f - 0.22) / 0.2, 2))) * energy * 1.6;
|
||||
const wob = 0.62 + 0.38 * Math.sin(t * (0.8 + f * 3.1) + i * 1.7) * Math.sin(t * 0.37 + i), pulse = kind === 'music' ? (0.5 + 0.5 * beat * (1 - f * 0.55)) : (0.35 + 0.65 * beat);
|
||||
const v = playing ? vizClamp(base * wob * pulse * 1.5 + 0.03 * Math.random()) : 0, s = v > B[i] ? 0.5 : 0.1; B[i] += (v - B[i]) * s;
|
||||
}
|
||||
}
|
||||
let b = 0, m = 0, h = 0, a = 0; for (let i = 0; i < VIZ_N; i++) { a += B[i]; if (i < 6) b += B[i]; else if (i < 24) m += B[i]; else h += B[i]; }
|
||||
viz.bass = b / 6; viz.mid = m / 18; viz.high = h / 24; const prev = viz.avg; viz.avg = a / VIZ_N;
|
||||
viz.beat = Math.max(0, viz.beat - dt * 3.2); if (viz.bass > 0.35 && viz.bass - (viz._pb || 0) > 0.05 || (!viz.live && kind === 'music' && viz.t - (viz._lb || 0) > 0.48 && playing)) { viz.beat = 1; viz._lb = viz.t; if (viz.rings.length < 5) viz.rings.push({ r: 0, a: 0.55 }); } viz._pb = viz.bass;
|
||||
}
|
||||
|
||||
// ---- drawing
|
||||
const vizRound = (g, x, y, w, h, r) => { r = Math.min(r, w / 2, h / 2); g.beginPath(); g.moveTo(x + r, y); g.arcTo(x + w, y, x + w, y + h, r); g.arcTo(x + w, y + h, x, y + h, r); g.arcTo(x, y + h, x, y, r); g.arcTo(x, y, x + w, y, r); g.closePath(); };
|
||||
function vizBackdrop(g, W, H) {
|
||||
g.globalCompositeOperation = 'source-over'; g.fillStyle = '#05060b'; g.fillRect(0, 0, W, H);
|
||||
if (viz.soft) { g.globalAlpha = 0.22 + 0.1 * viz.bass; g.drawImage(viz.soft, -W * 0.1, -H * 0.1, W * 1.2, H * 1.2); g.globalAlpha = 1; }
|
||||
const grd = g.createRadialGradient(W / 2, H * 0.45, 0, W / 2, H * 0.45, Math.max(W, H) * 0.75); grd.addColorStop(0, `hsla(${viz.hue},70%,45%,${0.16 + viz.bass * 0.25})`); grd.addColorStop(1, 'rgba(5,6,11,.82)'); g.fillStyle = grd; g.fillRect(0, 0, W, H);
|
||||
}
|
||||
function vizBars(g, W, H) {
|
||||
const B = viz.bands, gap = W / VIZ_N, bw = Math.max(3, gap * 0.6), base = H * 0.7, maxh = H * 0.46;
|
||||
for (let i = 0; i < VIZ_N; i++) {
|
||||
const h = Math.max(5, B[i] * maxh), x = i * gap + (gap - bw) / 2, grad = g.createLinearGradient(0, base - h, 0, base); grad.addColorStop(0, viz.pal[1]); grad.addColorStop(1, viz.pal[0]);
|
||||
g.fillStyle = grad; vizRound(g, x, base - h, bw, h, bw / 2); g.fill();
|
||||
g.globalAlpha = 0.16; g.fillStyle = viz.pal[0]; vizRound(g, x, base + 8, bw, h * 0.42, bw / 2); g.fill(); g.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
function vizWave(g, W, H) {
|
||||
g.globalCompositeOperation = 'lighter'; g.lineCap = 'round'; const t = viz.t, B = viz.bands;
|
||||
for (let L = 0; L < 4; L++) {
|
||||
const amp = H * (0.05 + 0.1 * (L === 0 ? viz.bass : L === 1 ? viz.mid : L === 2 ? viz.high * 1.4 : viz.avg)), f = 1.2 + L * 0.7, sp = 0.6 + L * 0.35;
|
||||
g.beginPath(); for (let x = 0; x <= W; x += 6) { const u = x / W, band = B[Math.floor(u * (VIZ_N - 1))]; const y = H * 0.48 + Math.sin(u * Math.PI * 2 * f + t * sp + L) * amp * (0.5 + band) + Math.sin(u * 17 + t * 1.7 + L * 2) * amp * 0.18 * band; x === 0 ? g.moveTo(x, y) : g.lineTo(x, y); }
|
||||
g.strokeStyle = viz.pal[L % 3]; g.lineWidth = 3 + L; g.globalAlpha = 0.75 - L * 0.1; g.shadowColor = viz.pal[L % 3]; g.shadowBlur = 18; g.stroke();
|
||||
}
|
||||
g.shadowBlur = 0; g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
function vizRingsDraw(g, W, H, dt) {
|
||||
const cx = W / 2, cy = H * 0.46, R = Math.min(W, H) * 0.18 * (1 + viz.bass * 0.14), B = viz.bands, N = 96;
|
||||
g.globalCompositeOperation = 'lighter';
|
||||
for (const r of viz.rings) { r.r += dt * R * 1.1; r.a -= dt * 0.35; g.beginPath(); g.arc(cx, cy, R * 1.15 + r.r, 0, Math.PI * 2); g.strokeStyle = viz.pal[1]; g.globalAlpha = Math.max(0, r.a); g.lineWidth = 2; g.stroke(); }
|
||||
viz.rings = viz.rings.filter(r => r.a > 0); g.globalAlpha = 1;
|
||||
g.lineCap = 'round'; g.lineWidth = Math.max(2, W * 0.007);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const v = B[i < N / 2 ? Math.floor(i / (N / 2) * (VIZ_N - 1)) : Math.floor((N - 1 - i) / (N / 2) * (VIZ_N - 1))], a = (i / N) * Math.PI * 2 - Math.PI / 2, r0 = R * 1.1, r1 = r0 + 4 + v * R * 0.95;
|
||||
g.strokeStyle = viz.pal[i % 3 === 0 ? 1 : 0]; g.globalAlpha = 0.45 + 0.55 * v; g.beginPath(); g.moveTo(cx + Math.cos(a) * r0, cy + Math.sin(a) * r0); g.lineTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1); g.stroke();
|
||||
}
|
||||
g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
|
||||
g.save(); g.beginPath(); g.arc(cx, cy, R, 0, Math.PI * 2); g.closePath(); g.clip();
|
||||
if (viz.art && viz.art.naturalWidth) g.drawImage(viz.art, cx - R, cy - R, R * 2, R * 2); else { const gr = g.createLinearGradient(cx - R, cy - R, cx + R, cy + R); gr.addColorStop(0, viz.pal[0]); gr.addColorStop(1, viz.pal[2]); g.fillStyle = gr; g.fillRect(cx - R, cy - R, R * 2, R * 2); }
|
||||
g.restore(); g.beginPath(); g.arc(cx, cy, R, 0, Math.PI * 2); g.strokeStyle = 'rgba(255,255,255,.35)'; g.lineWidth = 2; g.stroke();
|
||||
}
|
||||
function vizAurora(g, W, H) {
|
||||
g.globalCompositeOperation = 'lighter'; const t = viz.t * 0.35 + viz.ph;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const e = viz.bands[Math.floor((i / 6) * VIZ_N * 0.7)], x = W * (0.5 + 0.38 * Math.sin(t * (0.7 + i * 0.23) + i * 2.1)), y = H * (0.45 + 0.3 * Math.cos(t * (0.55 + i * 0.19) + i)), r = Math.max(W, H) * (0.18 + 0.08 * (i % 3) + e * 0.18);
|
||||
const gr = g.createRadialGradient(x, y, 0, x, y, r), col = viz.pal[i % 3]; gr.addColorStop(0, col.replace('hsl(', 'hsla(').replace(')', ` / ${0.14 + e * 0.22})`)); gr.addColorStop(1, col.replace('hsl(', 'hsla(').replace(')', ' / 0)')); g.fillStyle = gr; g.fillRect(0, 0, W, H);
|
||||
}
|
||||
g.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
function vizSparks(g, W, H, dt) {
|
||||
g.globalCompositeOperation = 'lighter';
|
||||
const n = Math.round(1 + viz.high * 9 + viz.beat * 14); for (let i = 0; i < n && viz.parts.length < 260; i++) { const a = Math.random() * Math.PI * 2, s = (0.04 + Math.random() * 0.14 + viz.bass * 0.25) * Math.min(W, H); viz.parts.push({ x: W / 2 + Math.cos(a) * W * 0.05, y: H * 0.5 + Math.sin(a) * W * 0.05, vx: Math.cos(a) * s, vy: Math.sin(a) * s - H * 0.02, life: 1, r: 1.5 + Math.random() * 3.5, c: Math.floor(Math.random() * 3) }); }
|
||||
for (const p of viz.parts) { p.x += p.vx * dt; p.y += p.vy * dt; p.vy += H * 0.01 * dt; p.vx *= 1 - dt * 0.4; p.life -= dt * 0.55; if (p.life <= 0) continue; g.globalAlpha = p.life * 0.9; g.fillStyle = viz.pal[p.c]; g.beginPath(); g.arc(p.x, p.y, p.r * (0.5 + p.life), 0, Math.PI * 2); g.fill(); }
|
||||
viz.parts = viz.parts.filter(p => p.life > 0); g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
|
||||
const R = Math.min(W, H) * 0.09 * (1 + viz.bass * 0.2); g.save(); g.beginPath(); g.arc(W / 2, H * 0.5, R, 0, Math.PI * 2); g.clip(); if (viz.art && viz.art.naturalWidth) g.drawImage(viz.art, W / 2 - R, H * 0.5 - R, R * 2, R * 2); else { g.fillStyle = viz.pal[0]; g.fillRect(W / 2 - R, H * 0.5 - R, R * 2, R * 2); } g.restore();
|
||||
}
|
||||
/** The old iTunes look: a picture that keeps zooming and turning into itself while the sound draws mirrored, colour-cycling curves on top. */
|
||||
function vizClassic(g, W, H, dt) {
|
||||
const cx = W / 2, cy = H * 0.46, B = viz.bands, R = Math.min(W, H);
|
||||
if (viz.fresh !== 'classic') { viz.fresh = 'classic'; g.globalCompositeOperation = 'source-over'; g.globalAlpha = 1; g.fillStyle = '#000'; g.fillRect(0, 0, W, H); }
|
||||
g.globalCompositeOperation = 'source-over'; g.save(); g.translate(cx, cy); g.rotate((0.004 + viz.mid * 0.012) * (Math.sin(viz.t * 0.3) > 0 ? 1 : -1)); const z = 1.012 + viz.bass * 0.03; g.scale(z, z); g.translate(-cx, -cy);
|
||||
g.globalAlpha = 0.9; g.drawImage(viz.cv, 0, 0, W, H); g.restore(); g.globalAlpha = 0.07; g.fillStyle = '#000'; g.fillRect(0, 0, W, H); g.globalAlpha = 1;
|
||||
g.globalCompositeOperation = 'lighter'; g.lineCap = 'round'; g.lineJoin = 'round'; const hue = (viz.hue + viz.t * 38) % 360, N = VIZ_N, T = viz.t;
|
||||
g.translate(cx, cy);
|
||||
for (let k = 0; k < 6; k++) for (let m = 0; m < 2; m++) {
|
||||
g.save(); g.rotate(k * Math.PI / 3 + T * 0.05); if (m) g.scale(1, -1);
|
||||
g.beginPath(); for (let i = 0; i <= N; i++) { const u = i / N, e = B[Math.min(N - 1, i)], a = u * Math.PI / 3 * 1.1, r = R * (0.06 + 0.5 * u * (0.35 + e) + 0.035 * Math.sin(u * 14 + T * 2)); const x = Math.cos(a) * r, y = Math.sin(a) * r; i ? g.lineTo(x, y) : g.moveTo(x, y); }
|
||||
g.strokeStyle = `hsl(${(hue + k * 24 + m * 60) % 360} 95% ${52 + viz.avg * 20}%)`; g.lineWidth = 1.4 + viz.bass * 3.5; g.globalAlpha = 0.55 + viz.avg * 0.4; g.stroke(); g.restore();
|
||||
}
|
||||
if (viz.beat > 0.2) { const gr = g.createRadialGradient(0, 0, 0, 0, 0, R * 0.35); gr.addColorStop(0, `hsla(${hue},100%,70%,${viz.beat * 0.6})`); gr.addColorStop(1, 'hsla(0,0%,0%,0)'); g.globalAlpha = 1; g.fillStyle = gr; g.fillRect(-cx, -cy, W, H); }
|
||||
g.setTransform(1, 0, 0, 1, 0, 0); const dpr = viz.cv.width / W; g.setTransform(dpr, 0, 0, dpr, 0, 0); g.globalAlpha = 1; g.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
function vizFrame(now) {
|
||||
viz.raf = requestAnimationFrame(vizFrame); if (!viz.g) return;
|
||||
const dt = Math.min(0.05, (now - (viz.last || now)) / 1000); viz.last = now; viz.t += dt;
|
||||
const t = cur(), playing = !pl.a.paused && !pl.buf, kind = vizKind(t), g = viz.g, W = viz.W, H = viz.H;
|
||||
vizBands(dt, playing, kind);
|
||||
if (viz.mode !== 'classic') { viz.fresh = ''; vizBackdrop(g, W, H); }
|
||||
({ classic: vizClassic, bars: vizBars, wave: vizWave, rings: vizRingsDraw, aurora: vizAurora, sparks: vizSparks }[viz.mode] || vizRingsDraw)(g, W, H, dt);
|
||||
const st = viz.el.querySelector('#vizst'), msg = viz.live ? 'Reacting to the sound' : viz.tapFailed ? 'Ambient (this browser cannot listen to the audio)' : viz.tap ? 'Ambient (this audio cannot be analysed here)' : 'Ambient';
|
||||
if (st.textContent !== msg) st.textContent = msg;
|
||||
if (viz.chromeT && now - viz.chromeT > 3500) { viz.el.classList.add('quiet'); viz.chromeT = 0; }
|
||||
}
|
||||
function vizSize() {
|
||||
if (!viz.cv) return; const dpr = Math.min(2, window.devicePixelRatio || 1), w = viz.cv.clientWidth, h = viz.cv.clientHeight;
|
||||
viz.cv.width = Math.round(w * dpr); viz.cv.height = Math.round(h * dpr); viz.g = viz.cv.getContext('2d'); viz.g.setTransform(dpr, 0, 0, dpr, 0, 0); viz.W = w; viz.H = h;
|
||||
}
|
||||
|
||||
// ---- the screen
|
||||
function vizOpen() {
|
||||
const t = cur(); if (!t || viz.open) return; viz.open = true; document.body.classList.add('vizopen');
|
||||
const kind = vizKind(t), saved = vizPref(kind); viz.mode = VIZ_MODES.some(m => m[0] === saved) ? saved : kind === 'speech' ? 'aurora' : 'classic'; viz.parts = []; viz.fresh = ''; viz.rings = []; viz.silent = 0; viz.live = false; viz.bands.fill(0);
|
||||
const el = viz.el = document.createElement('div'); el.id = 'viz'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Visualizer');
|
||||
el.innerHTML = `<canvas id="vizc"></canvas><div class="viztop"><div class="grow"><b id="vizti"></b><small id="vizar"></small><small id="vizst"></small></div><button class="btn ghost" id="vizm"></button><button class="ibtn" id="vizx" aria-label="Close" title="Close (Esc)">${icon('x')}</button></div>
|
||||
<div class="vizfoot"><button class="ibtn" id="vizpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="vizt" aria-label="Play or pause"></button><button class="ibtn" id="viznx" aria-label="Next">${icon('next')}</button></div>`;
|
||||
document.body.appendChild(el); viz.cv = el.querySelector('#vizc'); vizSize(); vizText(); vizColors(t);
|
||||
const m = el.querySelector('#vizm'); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1];
|
||||
m.onclick = e => { e.stopPropagation(); const i = VIZ_MODES.findIndex(x => x[0] === viz.mode); viz.mode = VIZ_MODES[(i + 1) % VIZ_MODES.length][0]; vizSetPref(vizKind(cur()), viz.mode); m.textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1]; viz.parts = []; viz.rings = []; };
|
||||
el.querySelector('#vizx').onclick = e => { e.stopPropagation(); vizClose(); }; el.querySelector('#vizt').onclick = e => { e.stopPropagation(); toggle(); }; el.querySelector('#vizpv').onclick = e => { e.stopPropagation(); prev(); }; el.querySelector('#viznx').onclick = e => { e.stopPropagation(); next(); };
|
||||
el.addEventListener('pointerdown', e => { if (e.target.closest('button')) return; el.classList.toggle('quiet'); viz.chromeT = el.classList.contains('quiet') ? 0 : performance.now(); }); // (tap the picture to hide or show the buttons)
|
||||
viz.chromeT = performance.now(); addEventListener('keydown', vizKey); addEventListener('resize', vizSize); addEventListener('hashchange', vizClose);
|
||||
vizTapStart(); viz.last = 0; viz.raf = requestAnimationFrame(vizFrame); vizWake(); syncUI();
|
||||
}
|
||||
function vizText() {
|
||||
const t = cur(); if (!t || !viz.el) return; viz.el.querySelector('#vizti').textContent = t.title; viz.el.querySelector('#vizar').textContent = t.artist + (t.album && vizKind(t) === 'music' ? ' · ' + t.album : '');
|
||||
const live = /^radio:/.test(String(t.key)); for (const id of ['vizpv', 'viznx']) viz.el.querySelector('#' + id).hidden = live;
|
||||
}
|
||||
/** A new song or episode (or a radio station's next song): the words, the colours and the cover change with it. */
|
||||
function vizTrackChanged() { if (!viz.open) return; const t = cur(); if (!t) return; vizText(); vizColors(t); const k = vizKind(t), s = vizPref(k); if (s && VIZ_MODES.some(m => m[0] === s)) viz.mode = s; else viz.mode = k === 'speech' ? 'aurora' : viz.mode; viz.el.querySelector('#vizm').textContent = VIZ_MODES.find(x => x[0] === viz.mode)[1]; }
|
||||
function vizClose() {
|
||||
if (!viz.open) return; viz.open = false; cancelAnimationFrame(viz.raf); removeEventListener('keydown', vizKey); removeEventListener('resize', vizSize); removeEventListener('hashchange', vizClose);
|
||||
vizTapStop(); if (viz.wake) { try { viz.wake.release(); } catch {} viz.wake = null; } if (viz.el) viz.el.remove(); viz.el = null; viz.cv = null; viz.g = null; document.body.classList.remove('vizopen');
|
||||
}
|
||||
const vizKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) vizClose(); else if (e.key === ' ' && !/^(INPUT|TEXTAREA|BUTTON|SELECT)$/.test(e.target.tagName || '')) { e.preventDefault(); toggle(); } };
|
||||
async function vizWake() { try { if (navigator.wakeLock) { viz.wake = await navigator.wakeLock.request('screen'); } } catch {} } // (keeps the screen on while you watch)
|
||||
document.addEventListener('visibilitychange', () => { if (viz.open && document.visibilityState === 'visible' && !viz.wake) vizWake(); });
|
||||
@@ -5,7 +5,7 @@ const fs = require('fs'), path = require('path'), http = require('http'), https
|
||||
const users = require('./users');
|
||||
|
||||
const FEATURED = [{
|
||||
id: 'journeyfm', name: 'The Journey FM', url: process.env.JOURNEYFM_STREAM_URL || 'http://ic.liberty.edu:8000/WRVL', homepage: 'https://www.myjourneyfm.com/', favicon: '', tags: 'christian,contemporary christian,worship',
|
||||
id: 'journeyfm', name: 'The Journey FM', url: process.env.JOURNEYFM_STREAM_URL || 'http://ic.liberty.edu:8000/WRVL', homepage: 'https://www.myjourneyfm.com/', favicon: 'https://www.myjourneyfm.com/wp-content/uploads/sites/2/2025/12/cropped-Version-4-192x192.png', tags: 'christian,contemporary christian,worship',
|
||||
country: 'United States', state: 'Virginia', codec: 'MP3', bitrate: 192, featured: true,
|
||||
}];
|
||||
const JOURNEY_NOW = () => process.env.JOURNEYFM_NOW_URL || 'https://www.myjourneyfm.com/wp-json/custom/v2/recentlyPlayed';
|
||||
@@ -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) {
|
||||
@@ -81,6 +91,27 @@ function icyFilter(metaint, onTitle) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- cover art for the song that is on: iTunes' free search (no key), else the station's logo, else a plain coloured tile with the station's initials
|
||||
const ITUNES = () => (process.env.ITUNES_URL || 'https://itunes.apple.com').replace(/\/+$/, '');
|
||||
const artCache = new Map(); // "artist|title" -> { t, url }
|
||||
const nrm = s => String(s || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/\(.*?\)|\[.*?\]/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
async function songArtUrl(artist, title) {
|
||||
const key = nrm(artist) + '|' + nrm(title), c = artCache.get(key); if (c && Date.now() - c.t < 6 * 3600e3) return c.url;
|
||||
let url = '';
|
||||
try {
|
||||
const d = JSON.parse(await readText(ITUNES() + '/search?' + new URLSearchParams({ term: artist + ' ' + title, entity: 'song', media: 'music', limit: '8' }), 1e6, { timeout: 8000 })), na = nrm(artist).split(' ')[0], nt = nrm(title);
|
||||
const hit = (d.results || []).find(x => { const t = nrm(x.trackName), a = nrm(x.artistName); return (t === nt || t.includes(nt) || nt.includes(t)) && (a.includes(na) || nrm(artist).includes(a.split(' ')[0])); });
|
||||
if (hit && hit.artworkUrl100) url = hit.artworkUrl100.replace(/\/\d+x\d+bb\./, '/600x600bb.');
|
||||
} catch {}
|
||||
if (artCache.size > 500) artCache.clear(); artCache.set(key, { t: Date.now(), url }); return url;
|
||||
}
|
||||
const initials = n => String(n || 'Radio').split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase().replace(/[<>&"']/g, '');
|
||||
const tile = n => { const hue = [...String(n)].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0); return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue} 60% 45%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 60% 28%)"/></linearGradient></defs><rect width="400" height="400" fill="url(#g)"/><text x="200" y="240" font-family="Georgia,serif" font-size="150" text-anchor="middle" fill="#fff" fill-opacity=".92">${initials(n)}</text></svg>`; };
|
||||
async function imageFrom(url, max = 1.5e6) { // a picture, or null
|
||||
try { const r = await open(url, { timeout: 8000 }), type = String(r.headers['content-type'] || '').split(';')[0].toLowerCase(); if (r.statusCode !== 200 || !/^image\/(jpeg|png|gif|webp)$/.test(type)) { r.resume(); return null; }
|
||||
const c = []; let n = 0; for await (const d of r) { n += d.length; if (n > max) { r.destroy(); return null; } c.push(d); } return { type, data: Buffer.concat(c) }; } catch { return null; }
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/radio'.length) || '/', me = users.current();
|
||||
try {
|
||||
@@ -100,8 +131,17 @@ 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 === '/art' && req.method === 'GET') { // never a broken picture: the song's cover, else the station's logo, else a tile
|
||||
const artist = clean(u.searchParams.get('artist')), title = clean(u.searchParams.get('title')); let img = null, name = 'Radio';
|
||||
const sid = u.searchParams.get('s') || ''; let st = null; try { st = sid ? await station(sid) : null; } catch {} if (st) name = st.name;
|
||||
if (artist && title) { const url = await songArtUrl(artist, title); if (url && (/(^|\.)mzstatic\.com$/.test(new URL(url).hostname) || process.env.ITUNES_URL)) img = await imageFrom(url); }
|
||||
if (!img && st && st.favicon) img = await imageFrom(st.favicon);
|
||||
if (img) { res.writeHead(200, { 'content-type': img.type, 'cache-control': 'private, max-age=86400', 'x-content-type-options': 'nosniff' }); return res.end(img.data); }
|
||||
res.writeHead(200, { 'content-type': 'image/svg+xml', 'cache-control': 'private, max-age=3600', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'x-content-type-options': 'nosniff' }); return res.end(tile(name));
|
||||
}
|
||||
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 +164,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 };
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
// "Save to Files": copy books, free audiobook chapters and Verse By Verse lessons and handouts into the person's own Seafile. The hub does the downloading and the
|
||||
// uploading itself (using the Seafile sign-in already saved for that person), so nothing big passes through the phone, and a whole study keeps going in the background
|
||||
// while you use the app. Files that are already there are skipped, so running it again just fills in what is missing.
|
||||
const fs = require('fs'), crypto = require('crypto');
|
||||
const settings = require('./settings'), users = require('./users'), books = require('./books'), vbv = require('./vbv'), librivox = require('./librivox');
|
||||
|
||||
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
|
||||
const MAX_FILE = 500 * 1024 * 1024;
|
||||
const TABS = { 'vbv-notes': 'podcast', 'vbv-lesson': 'podcast', 'vbv-study': 'podcast', book: 'read', 'librivox-book': 'audio', 'librivox-chapter': 'audio' };
|
||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||
const safe = (s, max = 100) => String(s || '').replace(/[\u0000-\u001f\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').replace(/^[. ]+|[. ]+$/g, '').slice(0, max).trim() || 'Untitled';
|
||||
const num = (n, of) => String(n + 1).padStart(Math.max(2, String(of).length), '0');
|
||||
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } };
|
||||
|
||||
// ---- Seafile, as the person
|
||||
function seafile(token) {
|
||||
const call = async (p, init = {}) => { const r = await fetch(SEAFILE() + p, { ...init, headers: { ...init.headers, Authorization: 'Token ' + token }, signal: AbortSignal.timeout(init.timeout || 30000) });
|
||||
if (r.status === 401) throw fail(401, 'Your Files sign-in has expired. Open Files and sign in again.'); return r; };
|
||||
const enc = encodeURIComponent;
|
||||
return {
|
||||
async list(repo, dir) { const r = await call(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`); if (r.status === 404) return null; if (!r.ok) throw fail(502, 'Files answered ' + r.status); return (await r.json()).map(x => ({ name: x.name, dir: x.type === 'dir' })); },
|
||||
async mkdir(repo, dir) { const r = await call(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`, { method: 'POST', body: new URLSearchParams({ operation: 'mkdir' }), headers: { 'content-type': 'application/x-www-form-urlencoded' } }); if (!r.ok && r.status !== 409) throw fail(r.status === 403 ? 403 : 502, r.status === 403 ? 'You cannot add files to that library' : 'Could not create the folder (' + r.status + ')'); },
|
||||
async upload(repo, dir, name, buf) {
|
||||
const lr = await call(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`); if (!lr.ok) throw fail(lr.status === 403 ? 403 : 502, lr.status === 403 ? 'You cannot add files to that library' : 'Files would not accept the upload (' + lr.status + ')');
|
||||
const link = await lr.json(), fd = new FormData(); fd.append('parent_dir', dir); fd.append('replace', '0'); fd.append('file', new Blob([buf]), name);
|
||||
const up = await fetch(new URL(link, SEAFILE()), { method: 'POST', body: fd, signal: AbortSignal.timeout(15 * 60e3) }); if (!up.ok) throw fail(502, 'The upload failed (' + up.status + ')');
|
||||
},
|
||||
};
|
||||
}
|
||||
async function download(url, allowed, ua) {
|
||||
if (!allowed(url)) throw fail(502, 'That address is not allowed');
|
||||
let r = await fetch(url, { headers: { 'user-agent': ua }, redirect: 'manual', signal: AbortSignal.timeout(10 * 60e3) });
|
||||
for (let i = 0; i < 4 && [301, 302, 303, 307, 308].includes(r.status); i++) { const to = new URL(r.headers.get('location') || '', url).href; if (!allowed(to)) throw fail(502, 'That address is not allowed'); url = to; r = await fetch(url, { headers: { 'user-agent': ua }, redirect: 'manual', signal: AbortSignal.timeout(10 * 60e3) }); }
|
||||
if (!r.ok) throw fail(502, 'The download failed (' + r.status + ')'); if ((+r.headers.get('content-length') || 0) > MAX_FILE) throw fail(413, 'That file is too large');
|
||||
const chunks = []; let n = 0; for await (const c of r.body) { n += c.length; if (n > MAX_FILE) throw fail(413, 'That file is too large'); chunks.push(c); } return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
// ---- what to save: a list of files ({ folder, name, load }), worked out before anything starts so the person gets an answer at once
|
||||
async function plan(me, b) {
|
||||
const kind = String(b.kind || ''), out = [];
|
||||
if (kind === 'book') {
|
||||
const f = books.fileOf(String(b.id || '')); if (!f) throw fail(404, 'Book not found');
|
||||
out.push({ folder: ['Books'], name: safe(f.author ? `${f.author} - ${f.title}` : f.title, 120) + '.' + f.ext, load: async () => fs.promises.readFile(f.path) });
|
||||
return { label: f.title, out };
|
||||
}
|
||||
if (kind === 'librivox-book' || kind === 'librivox-chapter') {
|
||||
const bk = await librivox.book(String(b.id || '')), only = kind === 'librivox-chapter' ? +b.n : -1;
|
||||
bk.chapters.forEach(c => { if (only >= 0 && c.n !== only) return; out.push({ folder: ['Audiobooks', safe(bk.title)], name: `${num(c.n, bk.chapters.length)} - ${safe(c.title, 80)}.mp3`, load: () => download(c.url, librivox.allowed, 'NE-Hub/1.0') }); });
|
||||
if (!out.length) throw fail(404, 'No chapters to save'); return { label: bk.title, out };
|
||||
}
|
||||
if (kind.startsWith('vbv-')) {
|
||||
const slug = String(b.slug || ''), st = await vbv.studyInfo(slug), folder = ['Verse By Verse', safe(st.title)], what = b.what || 'both';
|
||||
const lessons = kind === 'vbv-study' ? st.lessons : st.lessons.filter(l => l.n === +b.n); if (!lessons.length) throw fail(404, 'No such lesson');
|
||||
const wantAudio = kind === 'vbv-notes' ? false : what !== 'notes', wantNotes = kind === 'vbv-notes' ? true : what !== 'audio';
|
||||
for (const l of lessons) {
|
||||
const base = `${num(l.n, st.lessons.length)} - ${safe(l.title, 80)}`;
|
||||
if (wantAudio) out.push({ folder, name: base + '.mp3', load: async () => download(await vbv.audioUrl(slug, l.n), vbv.allowed, vbv.UA) });
|
||||
if (wantNotes) out.push({ folder, name: base + ' - Notes.pdf', optional: true, load: async () => download(await vbv.notesUrl(slug, l.n), vbv.allowed, vbv.UA) });
|
||||
}
|
||||
return { label: kind === 'vbv-study' ? st.title : `${st.title}, lesson ${+b.n + 1}`, out };
|
||||
}
|
||||
throw fail(400, 'Unknown thing to save');
|
||||
}
|
||||
|
||||
// ---- jobs
|
||||
const jobs = new Map();
|
||||
setInterval(() => { for (const [k, j] of jobs) if (Date.now() - j.t > 6 * 3600e3 && j.state !== 'running') jobs.delete(k); }, 600e3).unref();
|
||||
const view = j => ({ id: j.id, state: j.state, label: j.label, total: j.total, done: j.done, skipped: j.skipped, missing: j.missing, failed: j.failed, current: j.current, error: j.error, errors: j.errors.slice(0, 5), where: j.where, repo: j.repo, dir: j.dir });
|
||||
|
||||
async function run(j, token, items) {
|
||||
const sf = seafile(token), made = new Set(); let existing = new Map();
|
||||
const ensure = async folder => { let p = j.dir === '/' ? '' : j.dir; for (const part of folder) { p += '/' + part; if (made.has(p)) continue; const names = await sf.list(j.repo, p); if (names === null) await sf.mkdir(j.repo, p); made.add(p); } return (p || '/'); };
|
||||
try {
|
||||
for (const it of items) {
|
||||
if (j.cancelled) { j.state = 'cancelled'; return; }
|
||||
j.current = it.name; let target;
|
||||
try {
|
||||
target = await ensure(it.folder); if (!existing.has(target)) existing.set(target, new Set(((await sf.list(j.repo, target)) || []).map(x => x.name.toLowerCase())));
|
||||
if (existing.get(target).has(it.name.toLowerCase())) { j.skipped++; continue; }
|
||||
let buf; try { buf = await it.load(); } catch (e) { if (it.optional && e.code === 404) { j.missing++; continue; } throw e; }
|
||||
try { await sf.upload(j.repo, target, it.name, buf); } catch (e) { if (e.code === 401 || e.code === 403) throw e; await sf.upload(j.repo, target, it.name, buf); } // one more try
|
||||
existing.get(target).add(it.name.toLowerCase()); j.done++;
|
||||
} catch (e) { if (e.code === 401 || e.code === 403) { j.state = 'error'; j.error = e.message; return; } j.failed++; j.errors.push(`${it.name}: ${e.message}`); }
|
||||
}
|
||||
j.state = j.failed && !j.done && !j.skipped ? 'error' : 'done'; if (j.state === 'error') j.error = j.errors[0] || 'Nothing could be saved';
|
||||
} catch (e) { j.state = 'error'; j.error = e.message; } finally { j.current = ''; }
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/save'.length) || '/', me = users.current();
|
||||
try {
|
||||
let m;
|
||||
if (p === '/' && req.method === 'POST') {
|
||||
const b = await readBody(req), tab = TABS[b.kind]; if (!tab) throw fail(400, 'Unknown thing to save'); if (!users.can(me, tab)) throw fail(403, 'You do not have access to that');
|
||||
const token = settings.get('sf_token'); if (!token) throw fail(401, 'Open Files and sign in first, then try again');
|
||||
const repo = String(b.repo || ''), dir = '/' + String(b.dir || '/').split('/').filter(x => x && x !== '.' && x !== '..').join('/'); if (!/^[\w-]{8,64}$/.test(repo)) throw fail(400, 'Choose a library and folder');
|
||||
if ([...jobs.values()].filter(j => j.user === me.id && j.state === 'running').length >= 2) throw fail(429, 'Two saves are already running. Wait for one to finish.');
|
||||
const { label, out } = await plan(me, b);
|
||||
const j = { id: crypto.randomBytes(6).toString('hex'), user: me.id, t: Date.now(), state: 'running', label, total: out.length, done: 0, skipped: 0, missing: 0, failed: 0, current: '', error: '', errors: [], repo, dir, where: out[0].folder.join('/'), cancelled: false };
|
||||
jobs.set(j.id, j); run(j, token, out); return json(res, 200, view(j));
|
||||
}
|
||||
if ((m = /^\/job\/(\w+)$/.exec(p))) {
|
||||
const j = jobs.get(m[1]); if (!j || j.user !== me.id) throw fail(404, 'Not found');
|
||||
if (req.method === 'DELETE') { j.cancelled = true; return json(res, 200, view(j)); } return json(res, 200, view(j));
|
||||
}
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); }
|
||||
}
|
||||
|
||||
module.exports = { handle, safe, seafile };
|
||||
@@ -38,6 +38,8 @@ 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)
|
||||
const librivox = require('./librivox'); // free audiobooks from LibriVox
|
||||
@@ -545,10 +547,12 @@ const ACCESS = [
|
||||
[/^\/api\/(audiobooks|ab)\//, ['audio']],
|
||||
[/^\/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']],
|
||||
@@ -607,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');
|
||||
@@ -642,6 +646,8 @@ async function handleRequest(req, res) {
|
||||
if (pathname.startsWith('/api/read/')) return books.handle(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
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Book store: free, DRM-free books from Project Gutenberg (Gutendex), Standard Ebooks (OPDS), the Internet Archive (public-domain EPUBs) and any OPDS catalogue an
|
||||
// Book store: free, DRM-free books from Project Gutenberg (its own catalogue feed), Standard Ebooks (its public pages), the Internet Archive (public-domain EPUBs) and any OPDS catalogue an
|
||||
// admin adds (Calibre-Web, Kavita, ...). The hub server does all the talking to those sites: searching, covers and downloading a chosen book straight onto the
|
||||
// signed-in person's shelf (books.js), so the browser never contacts them and no catalogue sign-in reaches the browser.
|
||||
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
@@ -8,8 +8,8 @@ const UA = 'NE-Hub/1.0 (self-hosted e-book reader)';
|
||||
const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_TEXT = 4 * 1024 * 1024;
|
||||
const env = (k, d) => (process.env[k] || d).replace(/\/+$/, '');
|
||||
const BUILTIN = {
|
||||
gutenberg: { name: 'Project Gutenberg', base: () => env('GUTENDEX_URL', 'https://gutendex.com'), hosts: ['gutendex.com', 'gutenberg.org'] },
|
||||
standardebooks: { name: 'Standard Ebooks', base: () => env('STANDARD_EBOOKS_URL', 'https://standardebooks.org/feeds/opds'), hosts: ['standardebooks.org'], opds: true },
|
||||
gutenberg: { name: 'Project Gutenberg', base: () => env('GUTENBERG_URL', 'https://www.gutenberg.org'), hosts: ['gutenberg.org'] }, // its own catalogue feed (fast and official)
|
||||
standardebooks: { name: 'Standard Ebooks', base: () => env('STANDARD_EBOOKS_URL', 'https://standardebooks.org'), hosts: ['standardebooks.org'] }, // its public pages: the OPDS feed is for paying members only
|
||||
archive: { name: 'Internet Archive', base: () => env('ARCHIVE_URL', 'https://archive.org'), hosts: ['archive.org'], needsQuery: true },
|
||||
};
|
||||
const FORMAT_OF = { 'application/epub+zip': 'epub', 'application/pdf': 'pdf', 'application/x-mobipocket-ebook': 'mobi', 'application/vnd.amazon.mobi8-ebook': 'azw3', 'application/vnd.amazon.ebook': 'azw' };
|
||||
@@ -34,7 +34,7 @@ const sourceOf = id => {
|
||||
|
||||
// ---- talking to a site: only its own addresses are ever fetched (redirects included), a size limit applies, and a catalogue's sign-in goes nowhere else
|
||||
const sameSite = (src, u) => { const h = u.hostname; return src.builtin ? src.hosts.some(x => h === x || h.endsWith('.' + x)) : u.origin === new URL(src.base).origin; };
|
||||
async function get(src, url, { accept = '*/*', timeout = 10000 } = {}) {
|
||||
async function get(src, url, { accept = '*/*', timeout = 15000 } = {}) {
|
||||
let u = new URL(url);
|
||||
for (let hop = 0; hop < 6; hop++) {
|
||||
if (!/^https?:$/.test(u.protocol) || !sameSite(src, u)) throw fail(502, 'That address is not part of ' + src.name);
|
||||
@@ -57,6 +57,7 @@ const getJson = async (src, url) => { try { return JSON.parse(await getText(src,
|
||||
// ---- OPDS (Atom) feeds, read with a small parser (no XML library in the hub)
|
||||
const unent = s => String(s || '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[e.toLowerCase()] || (e[1] === 'x' || e[1] === 'X' ? String.fromCodePoint(parseInt(e.slice(2), 16)) : e[0] === '#' ? String.fromCodePoint(+e.slice(1)) : m)));
|
||||
const tag = (x, t) => { const m = new RegExp('<' + t + '(?:\\s[^>]*)?>([\\s\\S]*?)</' + t + '>', 'i').exec(x); return m ? unent(m[1]).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : ''; };
|
||||
const plain = s => unent(String(s || '').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
|
||||
const attrs = s => { const o = {}; for (const m of s.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) o[m[1].toLowerCase()] = unent(m[2] != null ? m[2] : m[3]); return o; };
|
||||
const linksOf = (x, base) => [...x.matchAll(/<link\b([^>]*?)\/?>/gi)].map(m => attrs(m[1])).filter(a => a.href).map(a => ({ ...a, href: new URL(a.href, base).href }));
|
||||
function parseFeed(xml, base) {
|
||||
@@ -78,7 +79,6 @@ async function opdsSearchUrl(src, q) {
|
||||
if (atom) return fill(unbrace(atom.href));
|
||||
const osd = root.links.find(l => l.rel === 'search' && /opensearchdescription/.test(l.type || ''));
|
||||
if (osd) { const x = await getText(src, osd.href, 'application/xml'); for (const m of x.matchAll(/<Url\b([^>]*?)\/?>/gi)) { const a = attrs(m[1]); if (/atom\+xml/.test(a.type || '') && a.template) return fill(unbrace(new URL(a.template.replace(/\{/g, '%7B').replace(/\}/g, '%7D'), osd.href).href)); } }
|
||||
if (src.id === 'standardebooks') return src.base + '/all?query=' + enc;
|
||||
throw fail(502, src.name + ' does not offer a search');
|
||||
}
|
||||
async function opdsSearch(src, q, page) {
|
||||
@@ -91,17 +91,33 @@ async function opdsSearch(src, q, page) {
|
||||
return { items, more: !!feed.links.find(l => l.rel === 'next') };
|
||||
}
|
||||
|
||||
// ---- Project Gutenberg through Gutendex, and the Internet Archive
|
||||
const tidyName = n => { const m = /^([^,]+),\s*(.+)$/.exec(n || ''); return m ? (m[2] + ' ' + m[1]).replace(/\s*\(.*?\)/g, '').trim() : String(n || ''); };
|
||||
// ---- Project Gutenberg (its own OPDS catalogue), Standard Ebooks (its public pages), and the Internet Archive
|
||||
const entriesOf = xml => [...xml.matchAll(/<entry[\s>][\s\S]*?<\/entry>/gi)].map(m => m[0]);
|
||||
async function gutenbergSearch(src, q, page) {
|
||||
const j = await getJson(src, `${src.base}/books/?${q ? 'search=' + encodeURIComponent(q) + '&' : ''}page=${page}`);
|
||||
const items = (j.results || []).filter(b => (b.formats || {})['application/epub+zip']).map(b => ({ ref: String(b.id), format: 'epub', title: b.title, author: (b.authors || []).map(a => tidyName(a.name)).join(', '), summary: (b.subjects || []).slice(0, 3).join(' · '), cover: (b.formats || {})['image/jpeg'] || '' }));
|
||||
return { items, more: !!j.next };
|
||||
const url = `${src.base}/ebooks/search.opds/?${q ? 'query=' + encodeURIComponent(q) : 'sort_order=downloads'}${page > 1 ? '&start_index=' + ((page - 1) * 25 + 1) : ''}`;
|
||||
const xml = await getText(src, url, 'application/atom+xml'), items = [], seen = new Set();
|
||||
for (const e of entriesOf(xml)) { const id = (/\/ebooks\/(\d+)\.opds/.exec(tag0(e, 'id')) || [])[1]; if (!id || seen.has(id)) continue; seen.add(id); items.push({ ref: id, format: 'epub', title: tag(e, 'title'), author: tag(e, 'content'), summary: '', cover: `${src.base}/cache/epub/${id}/pg${id}.cover.medium.jpg` }); }
|
||||
return { items, more: /<link[^>]+rel="next"/i.test(xml.replace(/<entry[\s>][\s\S]*?<\/entry>/gi, '')) };
|
||||
}
|
||||
const tag0 = (x, t) => (new RegExp('<' + t + '>([^<]*)</' + t + '>', 'i').exec(x) || [])[1] || '';
|
||||
async function gutenbergResolve(src, ref) {
|
||||
if (!/^\d{1,9}$/.test(ref)) throw fail(400, 'Bad book');
|
||||
const b = await getJson(src, `${src.base}/books/${ref}`), url = (b.formats || {})['application/epub+zip']; if (!url) throw fail(404, 'No EPUB for that book');
|
||||
return { url, format: 'epub' };
|
||||
return { url: `${src.base}/ebooks/${ref}.epub3.images`, format: 'epub' }; // (the address Project Gutenberg itself gives for the EPUB with pictures)
|
||||
}
|
||||
async function standardSearch(src, q, page) {
|
||||
const html = await getText(src, `${src.base}/ebooks?${q ? 'query=' + encodeURIComponent(q) + '&' : ''}page=${page}&per-page=24`, 'text/html, application/xhtml+xml'), items = [];
|
||||
for (const m of html.matchAll(/<li typeof="schema:Book" about="(\/ebooks\/[^"]+)">([\s\S]*?)<\/li>/g)) {
|
||||
const card = m[2], title = plain((/<p><a [^>]*property="schema:url"><span property="schema:name">([\s\S]*?)<\/span>/.exec(card) || [])[1]);
|
||||
const who = [...(/class="author"[\s\S]*?<\/p>/.exec(card) || [''])[0].matchAll(/<span property="schema:name">([\s\S]*?)<\/span>/g)].map(x => plain(x[1])).join(', '), img = (/<img src="([^"]+)"/.exec(card) || [])[1] || '';
|
||||
if (title) items.push({ ref: m[1], format: 'epub', title, author: who, summary: '', cover: img ? new URL(unent(img), src.base).href : '' });
|
||||
}
|
||||
return { items, more: new RegExp('href="[^"]*[?&;]page=' + (page + 1) + '[^0-9]').test(html) };
|
||||
}
|
||||
async function standardResolve(src, ref) {
|
||||
if (!/^\/ebooks\/[\w\/.-]+$/.test(ref) || ref.includes('..')) throw fail(400, 'Bad book');
|
||||
const html = await getText(src, src.base + ref, 'text/html'), links = [...html.matchAll(/href="([^"]*\/downloads\/[^"]+\.epub)"/g)].map(m => unent(m[1])).filter(h => !/kepub|_advanced/.test(h));
|
||||
const href = links[0] || `${ref}/downloads/${ref.replace(/^\/ebooks\//, '').split('/').join('_')}.epub`;
|
||||
return { url: new URL(href, src.base).href + '?source=download', format: 'epub' }; // (without this the site answers with a "your download has started" page)
|
||||
}
|
||||
async function archiveSearch(src, q, page) {
|
||||
const query = `(${q}) AND mediatype:texts AND format:EPUB AND -access-restricted-item:true`;
|
||||
@@ -119,6 +135,7 @@ async function archiveResolve(src, ref) {
|
||||
|
||||
async function searchOne(src, q, page) {
|
||||
if (src.id === 'gutenberg') return gutenbergSearch(src, q, page);
|
||||
if (src.id === 'standardebooks') return standardSearch(src, q, page);
|
||||
if (src.id === 'archive') return q ? archiveSearch(src, q, page) : { items: [], more: false };
|
||||
return opdsSearch(src, q, page);
|
||||
}
|
||||
@@ -161,6 +178,7 @@ async function handle(req, res, p) {
|
||||
const b = await readBody(req), src = sourceOf(String(b.source || '')), ref = String(b.ref || '');
|
||||
let target;
|
||||
if (src.id === 'gutenberg') target = await gutenbergResolve(src, ref);
|
||||
else if (src.id === 'standardebooks') target = await standardResolve(src, ref);
|
||||
else if (src.id === 'archive') target = await archiveResolve(src, ref);
|
||||
else { if (!sameSite(src, new URL(ref, src.base))) throw fail(400, 'Bad book address'); target = { url: ref, format: String(b.format || 'epub') }; }
|
||||
const ext = books.TYPES[target.format] ? target.format : 'epub';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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')}`;
|
||||
|
||||
@@ -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')
|
||||
+12
-5
@@ -2,9 +2,9 @@
|
||||
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 seen = []; 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)); };
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(p); const J = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
||||
if (p === '/json/stations/search') return J([
|
||||
{ stationuuid: 'aaaaaaaa-1111', name: 'Good FM', url_resolved: `http://127.0.0.1:${port}/stream`, codec: 'MP3', bitrate: 128, tags: 'christian,worship,pop,extra,more', country: 'US', state: 'Ohio', hls: 0, homepage: 'https://good.fm', favicon: `http://127.0.0.1:${port}/logo.png` },
|
||||
{ stationuuid: 'bbbbbbbb-2222', name: 'HLS only', url_resolved: `http://127.0.0.1:${port}/x.m3u8`, codec: 'AAC', hls: 1 }, { stationuuid: 'cccccccc-3333', name: 'Odd codec', url_resolved: 'http://a/b', codec: 'FLAC', hls: 0 }, { stationuuid: 'dddddddd-4444', name: ' ', url_resolved: 'http://a/b', codec: 'MP3', hls: 0 }]);
|
||||
@@ -15,11 +15,13 @@ const fake = http.createServer((req, res) => {
|
||||
return res.end(Buffer.concat([AUDIO.subarray(0, 1024), Buffer.from([blk.length / 16]), blk, AUDIO.subarray(1024, 2048), Buffer.from([0]), AUDIO.subarray(2048, 3072)]));
|
||||
}
|
||||
if (p === '/pls') { res.writeHead(200, { 'content-type': 'audio/x-scpls' }); return res.end('[playlist]'); }
|
||||
if (p === '/search') { const term = u.searchParams.get('term') || ''; return J({ results: /Great Song/.test(term) ? [{ trackName: 'Other Thing', artistName: 'Else', artworkUrl100: `http://127.0.0.1:${port}/art/wrong-100x100bb.gif` }, { trackName: 'Great Song (Radio Edit)', artistName: 'Some Band', artworkUrl100: `http://127.0.0.1:${port}/art/100x100bb.gif` }] : [] }); }
|
||||
if (p === '/art/600x600bb.gif') { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(Buffer.from('GIF89a-cover')); }
|
||||
if (p === '/logo.png') { res.writeHead(200, { 'content-type': 'image/png' }); return res.end(Buffer.from('89504e470d0a1a0a', 'hex')); }
|
||||
if (p === '/journey/getRecentlyPlayed' || p === '/journey/getNowPlaying') return J([{ TITLE: 'THE JOURNEY', ARTIST: null, ACTIVITY_DATE: '10/24/2026 22:01:21' }, { TITLE: "A LIL' CHURCH (NOBODY'S TOO LOST)", ARTIST: 'TOBYMAC', ACTIVITY_DATE: '10/24/2026 22:01:37' }, { TITLE: 'RESCUE', ARTIST: 'JORDAN ST. CYR', ACTIVITY_DATE: '10/24/2026 21:57:43' }]);
|
||||
res.writeHead(404); res.end();
|
||||
}).listen(0, () => {
|
||||
port = fake.address().port; Object.assign(process.env, { RADIO_BROWSER_URL: `http://127.0.0.1:${port}`, JOURNEYFM_NOW_URL: `http://127.0.0.1:${port}/journey`, JOURNEYFM_STREAM_URL: `http://127.0.0.1:${port}/stream` });
|
||||
port = fake.address().port; Object.assign(process.env, { RADIO_BROWSER_URL: `http://127.0.0.1:${port}`, JOURNEYFM_NOW_URL: `http://127.0.0.1:${port}/journey`, JOURNEYFM_STREAM_URL: `http://127.0.0.1:${port}/stream`, ITUNES_URL: `http://127.0.0.1:${port}` });
|
||||
const radio = require('../radio'), users = require('../users');
|
||||
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['radio'], handle: 'Yg' }) };
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => radio.handle(req, res))).listen(0, async () => {
|
||||
@@ -32,11 +34,16 @@ 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);
|
||||
r = await call('/art?s=aaaaaaaa-1111&artist=Some%20Band&title=Great%20Song'); assert.equal(r.headers.get('content-type'), 'image/gif'); assert.equal((await r.text()), 'GIF89a-cover', "the cover of the song that is on (the right match, at the larger size)"); assert.ok(seen.includes('/art/600x600bb.gif'));
|
||||
r = await call('/art?s=aaaaaaaa-1111&artist=Nobody&title=Nothing'); assert.equal(r.headers.get('content-type'), 'image/png', 'no cover found: the station logo');
|
||||
r = await call('/art?s=eeeeeeee-5555'); assert.equal(r.headers.get('content-type'), 'image/svg+xml', 'no logo either: a coloured tile'); assert.match(await r.text(), />PF</, 'with the station initials');
|
||||
r = await call('/art'); assert.equal(r.status, 200); assert.equal(r.headers.get('content-type'), 'image/svg+xml', 'never a broken picture');
|
||||
// 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');
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// "Save to Files": books, free audiobook chapters and Verse By Verse lessons are copied into the person's Seafile (made folders, skipped files already there, handouts that do not exist).
|
||||
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), cp = require('child_process');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-save-'));
|
||||
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-savefx-')); cp.execFileSync('python3', [path.join(__dirname, 'mkbooks.py'), fx]); const epub = fs.readFileSync(path.join(fx, 'test.epub'));
|
||||
const tree = new Map([['/', new Set()]]), files = [], seen = []; let port; // fake Seafile: folders and uploaded files
|
||||
const fake = http.createServer(async (req, res) => {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname, J = (o, c = 200) => { res.writeHead(c, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); }, T = (t, b = '') => { res.writeHead(200, { 'content-type': t }); res.end(b); };
|
||||
seen.push(req.method + ' ' + p + (u.searchParams.get('p') ? ' ' + u.searchParams.get('p') : ''));
|
||||
if (p.startsWith('/api2/repos/') || p.startsWith('/seafhttp/')) { if (p.startsWith('/api2/') && req.headers.authorization !== 'Token SFTOKEN') { res.writeHead(401); return res.end(); } }
|
||||
if (/^\/api2\/repos\/R{8}\/dir\/$/.test(p)) { const d = u.searchParams.get('p');
|
||||
if (req.method === 'POST') { const par = d.replace(/\/[^/]+$/, '') || '/'; if (!tree.has(par)) { res.writeHead(404); return res.end(); } tree.set(d, new Set()); tree.get(par).add('D:' + d.split('/').pop()); return J('success', 201); }
|
||||
if (!tree.has(d)) { res.writeHead(404); return res.end('{}'); } return J([...tree.get(d)].map(n => n.startsWith('D:') ? { name: n.slice(2), type: 'dir' } : { name: n, type: 'file' })); }
|
||||
if (/^\/api2\/repos\/R{8}\/upload-link\/$/.test(p)) return J(`http://127.0.0.1:${port}/seafhttp/upload-api/tok?dir=${encodeURIComponent(u.searchParams.get('p'))}`);
|
||||
if (p === '/seafhttp/upload-api/tok') { const b = Buffer.concat(await req.toArray()); const name = /filename="([^"]+)"/.exec(b.toString('latin1'))[1]; const d = u.searchParams.get('dir'); if (!tree.has(d)) { res.writeHead(404); return res.end(); }
|
||||
if (name === 'fail.mp3') { res.writeHead(500); return res.end(); } tree.get(d).add(name); files.push(d + '/' + name + ' ' + b.length); return T('text/plain', 'id'); }
|
||||
// sources: Verse By Verse, LibriVox
|
||||
if (p === '/bible-studies/jude/podcast.xml') return T('application/xml', `<rss><channel><title>Jude: Faith</title><itunes:author>Wes</itunes:author><itunes:image href="http://127.0.0.1:${port}/c.gif"/>` + [1, 2, 3].map(n => `<item><title>${n} Lesson ${n}</title><link>http://127.0.0.1:${port}/lessons/jude-${n}</link><enclosure url="http://127.0.0.1:${port}/media/${n}.mp3" length="5" type="audio/mpeg"/></item>`).join('') + '</channel></rss>');
|
||||
if (/^\/lessons\/jude-[12]$/.test(p)) return T('text/html', `<a href="http://127.0.0.1:${port}/media/notes${p.slice(-1)}.pdf">n</a>`); if (p === '/lessons/jude-3') return T('text/html', 'none');
|
||||
if (/^\/media\/\d\.mp3$/.test(p)) return T('audio/mpeg', 'AUDIO' + p); if (/^\/media\/notes\d\.pdf$/.test(p)) return T('application/pdf', '%PDF-1.4 notes');
|
||||
if (p === '/api/feed/audiobooks/') { const base = { id: '9', title: 'Old: Story?', totaltimesecs: 60, authors: [{ first_name: 'A', last_name: 'B' }] }; return J({ books: [{ ...base, sections: [1, 2].map(n => ({ title: 'Ch ' + n + '/x', listen_url: `http://127.0.0.1:${port}/media/${n}.mp3`, playtime: '30' })) }] }); }
|
||||
res.writeHead(404); res.end();
|
||||
}).listen(0, () => {
|
||||
port = fake.address().port; Object.assign(process.env, { SEAFILE_URL: 'http://127.0.0.1:' + port, VBV_URL: 'http://127.0.0.1:' + port, LIBRIVOX_URL: 'http://127.0.0.1:' + port }); tree.set('/Lib', new Set());
|
||||
const users = require('../users'), books = require('../books'), save = require('../savefiles');
|
||||
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bob = users.create({ name: 'Bob', role: 'user', tabs: ['files'], handle: 'Yg' }), noTok = users.create({ name: 'Cy', role: 'admin', handle: 'Yw' });
|
||||
const as = (u, f) => users.runAs(u, f); as(ann, () => fs.writeFileSync(path.join(users.dir(), 'settings.json'), JSON.stringify({ sf_token: 'SFTOKEN' }))); as(bob, () => fs.writeFileSync(path.join(users.dir(), 'settings.json'), JSON.stringify({ sf_token: 'SFTOKEN' })));
|
||||
const bookId = as(ann, () => books.addBook(epub, 'epub', { title: 'My Book', author: 'Some Author' }).id);
|
||||
const hub = http.createServer((req, res) => as({ b: bob, c: noTok }[req.headers['x-user']] || ann, () => save.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/save', R = 'RRRRRRRR', j = r => r.json(), post = (b, h = {}) => fetch(base + '/', { method: 'POST', body: JSON.stringify({ repo: R, dir: '/Lib', ...b }), headers: h });
|
||||
const finish = async id => { for (let i = 0; i < 100; i++) { const s = await j(await fetch(base + '/job/' + id)); if (s.state !== 'running') return s; await new Promise(ok => setTimeout(ok, 50)); } throw new Error('job did not finish'); };
|
||||
try {
|
||||
let r = await post({ kind: 'book', id: bookId }); let s = await finish((await j(r)).id); assert.equal(s.state, 'done'); assert.equal(s.done, 1); assert.ok(files.some(f => f.startsWith('/Lib/Books/Some Author - My Book.epub ')), 'the book is in a Books folder, named by author and title');
|
||||
r = await post({ kind: 'book', id: bookId }); s = await finish((await j(r)).id); assert.equal(s.skipped, 1); assert.equal(s.done, 0, 'running it again skips what is already there');
|
||||
r = await post({ kind: 'vbv-lesson', slug: 'jude', n: 0, what: 'both' }); s = await finish((await j(r)).id); assert.equal(s.done, 2); assert.ok(files.some(f => f.startsWith('/Lib/Verse By Verse/Jude Faith/01 - Lesson 1.mp3')), 'folders are made and names are safe'); assert.ok(files.some(f => f.includes('01 - Lesson 1 - Notes.pdf')));
|
||||
r = await post({ kind: 'vbv-study', slug: 'jude', what: 'both' }); s = await finish((await j(r)).id); assert.equal(s.total, 6); assert.equal(s.skipped, 2, 'lesson 1 was already saved'); assert.equal(s.missing, 1, 'a lesson with no handout is counted, not failed'); assert.equal(s.failed, 0); assert.equal(s.done, 3);
|
||||
assert.ok(files.some(f => f.includes('03 - Lesson 3.mp3')) && !files.some(f => f.includes('03 - Lesson 3 - Notes')));
|
||||
r = await post({ kind: 'librivox-book', id: '9', dir: '/Lib/Sub' }); assert.equal((await finish((await j(r)).id)).state, 'error', 'a folder that does not exist in Files is an error, not a crash');
|
||||
r = await post({ kind: 'librivox-book', id: '9' }); s = await finish((await j(r)).id); assert.equal(s.done, 2); assert.ok(files.some(f => f.startsWith('/Lib/Audiobooks/Old Story/01 - Ch 1 x.mp3')), 'odd characters in names are removed');
|
||||
r = await post({ kind: 'book', id: bookId }, { 'x-user': 'b' }); assert.equal(r.status, 403, 'a person without the Books tab cannot save books');
|
||||
r = await post({ kind: 'book', id: bookId }, { 'x-user': 'c' }); assert.equal(r.status, 401, 'no Files sign-in saved: told to sign in'); assert.match((await j(r)).error, /sign in/);
|
||||
assert.equal((await post({ kind: 'nope' })).status, 400); assert.equal((await post({ kind: 'book', id: bookId, repo: '../x' })).status, 400); assert.equal((await post({ kind: 'book', id: 'zzz' })).status, 404);
|
||||
r = await post({ kind: 'book', id: bookId, dir: '/Lib/../../etc' }); s = await finish((await j(r)).id); assert.ok(!s.dir.includes('..'), 'a folder path cannot climb out');
|
||||
assert.equal((await fetch(base + '/job/' + s.id, { headers: { 'x-user': 'b' } })).status, 404, "another person's save is not visible");
|
||||
console.log('savefiles test ok');
|
||||
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||
hub.close(); fake.close();
|
||||
});
|
||||
});
|
||||
+30
-25
@@ -6,25 +6,28 @@ const epub = fs.readFileSync(path.join(fx, 'test.epub')), pdf = fs.readFileSync(
|
||||
const seen = []; let port;
|
||||
const feed = (body, links = '') => `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom">${links}${body}</feed>`;
|
||||
const acq = (id, title, author, href, type = 'application/epub+zip') => `<entry><title>${title}</title><author><name>${author}</name></author><id>${id}</id><summary>A & B</summary><link rel="http://opds-spec.org/image" href="/img/${id}.gif" type="image/gif"/><link rel="http://opds-spec.org/acquisition/open-access" href="${href}" type="${type}"/></entry>`;
|
||||
const seCard = (slug, title, who) => `<li typeof="schema:Book" about="/ebooks/${slug}"><div><a href="/ebooks/${slug}"><picture><img src="/img/${slug.replace(/\W/g, '')}.gif" alt="" property="schema:image"/></picture></a></div><p><a href="/ebooks/${slug}" property="schema:url"><span property="schema:name">${title}</span></a></p><p class="author" typeof="schema:Person"><a href="https://x/y" property="schema:url"><span property="schema:name">${who}</span></a></p></li>`;
|
||||
const fake = http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(req.method + ' ' + p + u.search + (req.headers.authorization ? ' [auth]' : ''));
|
||||
const J = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); }, X = x => { res.writeHead(200, { 'content-type': 'application/atom+xml' }); res.end(x); };
|
||||
if (p === '/gut/books/' ) return J({ next: u.searchParams.get('page') === '1' ? 'x' : null, results: [{ id: 11, title: 'Alice', authors: [{ name: 'Carroll, Lewis' }], subjects: ['Fantasy'], formats: { 'application/epub+zip': `http://127.0.0.1:${port}/gut/file/11.epub`, 'image/jpeg': `http://127.0.0.1:${port}/img/11.gif` } }, { id: 12, title: 'No epub', authors: [], formats: {} }] });
|
||||
if (p === '/gut/books/11') return J({ id: 11, formats: { 'application/epub+zip': `http://127.0.0.1:${port}/gut/file/11.epub` } });
|
||||
if (p === '/gut/file/11.epub') { res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(epub); }
|
||||
if (p === '/gut/books/66') return J({ id: 66, formats: { 'application/epub+zip': `http://127.0.0.1:${port}/gut/evil` } });
|
||||
if (p === '/gut/evil') { res.writeHead(302, { location: `http://localhost:${port}/secret` }); return res.end(); }
|
||||
if (p === '/img/11.gif' || p.startsWith('/img/')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(gif); }
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(p + u.search);
|
||||
const J = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); }, X = x => { res.writeHead(200, { 'content-type': 'application/atom+xml' }); res.end(x); }, HT = x => { res.writeHead(200, { 'content-type': 'application/xhtml+xml' }); res.end(x); };
|
||||
// Project Gutenberg's catalogue feed
|
||||
if (p === '/ebooks/search.opds/') { const more = u.searchParams.get('start_index') ? '' : '<link rel="next" type="application/atom+xml" href="/ebooks/search.opds/?query=x&start_index=26"/>';
|
||||
return X(feed(`<entry><title>Authors</title><id>https://x/ebooks/author/1.opds</id></entry><entry><title>Alice in Wonderland</title><id>https://www.gutenberg.org/ebooks/11.opds</id><content type="text">Lewis Carroll</content></entry><entry><title>Alice in Wonderland</title><id>https://www.gutenberg.org/ebooks/11.opds</id><content>Lewis Carroll</content></entry><entry><title>Dracula & Co</title><id>https://www.gutenberg.org/ebooks/345.opds</id><content>Bram Stoker</content></entry>`, more)); }
|
||||
if (p === '/ebooks/11.epub3.images') { res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(epub); }
|
||||
if (p === '/ebooks/66.epub3.images') { res.writeHead(302, { location: `http://localhost:${port}/secret` }); return res.end(); }
|
||||
if (p.startsWith('/cache/epub/') || p.startsWith('/img/')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(gif); }
|
||||
// Standard Ebooks' public pages (their OPDS feed is for members only)
|
||||
if (p === '/ebooks') { const q = u.searchParams.get('query') || ''; return HT(`<html><body><ol class="ebooks-list grid">${seCard('bram-stoker/dracula', 'Dracula ' + q, 'Bram Stoker')}${seCard('john-bunyan/the-pilgrims-progress', 'The Pilgrim’s Progress', 'John Bunyan')}</ol><nav><a href="/ebooks?page=2">2</a></nav></body></html>`); }
|
||||
if (p === '/ebooks/bram-stoker/dracula') return HT(`<a href="/ebooks/bram-stoker/dracula/downloads/bram-stoker_dracula.kepub.epub">k</a><a href="/ebooks/bram-stoker/dracula/downloads/bram-stoker_dracula_advanced.epub">a</a><a href="/ebooks/bram-stoker/dracula/downloads/bram-stoker_dracula.epub?source=download">e</a>`);
|
||||
if (p === '/ebooks/bram-stoker/dracula/downloads/bram-stoker_dracula.epub') { if (u.searchParams.get('source') !== 'download') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<html>Your Download Has Started!</html>'); } res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(Buffer.concat([epub, Buffer.from('se')])); }
|
||||
if (p === '/feeds/opds') { res.writeHead(401); return res.end('members only'); }
|
||||
// the Internet Archive
|
||||
if (p === '/ia/advancedsearch.php') return J({ response: { numFound: 1, docs: [{ identifier: 'moby1', title: 'Moby', creator: ['Melville'] }, { identifier: '../bad', title: 'x' }] } });
|
||||
if (p === '/ia/metadata/moby1') return J({ metadata: {}, files: [{ name: 'moby1.pdf', format: 'Text PDF' }, { name: 'moby1.epub', format: 'EPUB' }] });
|
||||
if (p === '/ia/metadata/locked') return J({ metadata: { 'access-restricted-item': 'true' }, files: [{ name: 'x.epub', format: 'EPUB' }] });
|
||||
if (p === '/ia/download/moby1/moby1.epub') { res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(Buffer.concat([epub, Buffer.from('ia')])); }
|
||||
if (p === '/se/opds') return X(feed('<entry><title>New releases</title><id>n</id><link rel="http://opds-spec.org/sort/new" href="/se/opds/new" type="application/atom+xml"/></entry>', '<link rel="search" href="/se/opds/all?query={searchTerms}" type="application/atom+xml"/>'));
|
||||
if (p === '/se/opds/all') return X(feed(acq('se1', 'Dracula ' + u.searchParams.get('query'), 'Bram Stoker', '/se/dl/dracula.epub')));
|
||||
if (p === '/se/opds/new') return X(feed(acq('se2', 'Newest Book', 'Someone', '/se/dl/new.epub'), '<link rel="next" href="/se/opds/new2" type="application/atom+xml"/>'));
|
||||
if (p === '/se/opds/new2') return X(feed(acq('se3', 'Page Two', 'Someone', '/se/dl/new.epub')));
|
||||
if (p === '/se/dl/dracula.epub' || p === '/se/dl/new.epub') { res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(epub); }
|
||||
if (p.startsWith('/cal/')) { // Calibre-Web style: needs a sign-in; search is described by an OpenSearch file
|
||||
// an OPDS catalogue that needs a sign-in (Calibre-Web style)
|
||||
if (p.startsWith('/cal/')) {
|
||||
if (req.headers.authorization !== 'Basic ' + Buffer.from('reader:pw').toString('base64')) { res.writeHead(401); return res.end(); }
|
||||
if (p === '/cal/opds') return X(feed('', '<link rel="search" href="/cal/opds/osd" type="application/opensearchdescription+xml"/>'));
|
||||
if (p === '/cal/opds/osd') return X('<OpenSearchDescription><Url type="application/atom+xml" template="/cal/opds/search/{searchTerms}"/></OpenSearchDescription>');
|
||||
@@ -34,7 +37,7 @@ const fake = http.createServer((req, res) => {
|
||||
res.writeHead(404); res.end('no');
|
||||
}).listen(0, async () => {
|
||||
port = fake.address().port; const B = 'http://127.0.0.1:' + port;
|
||||
Object.assign(process.env, { GUTENDEX_URL: B + '/gut', ARCHIVE_URL: B + '/ia', STANDARD_EBOOKS_URL: B + '/se/opds' });
|
||||
Object.assign(process.env, { GUTENBERG_URL: B, ARCHIVE_URL: B + '/ia', STANDARD_EBOOKS_URL: B });
|
||||
const books = require('../books'), users = require('../users');
|
||||
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['read'], handle: 'Yg' }) };
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => books.handle(req, res))).listen(0, async () => {
|
||||
@@ -42,24 +45,26 @@ const fake = http.createServer((req, res) => {
|
||||
try {
|
||||
let r = await call('/sources'); assert.deepEqual((await r.json()).map(s => s.id), ['gutenberg', 'standardebooks', 'archive']);
|
||||
r = await call('/search?q=alice'); let j = await r.json();
|
||||
assert.deepEqual(j.items.map(i => i.source), ['gutenberg', 'standardebooks', 'archive'], 'one result from each catalogue in turn, books with no EPUB left out');
|
||||
assert.equal(j.items[0].author, 'Lewis Carroll'); assert.equal(j.items[1].summary, 'A & B'); assert.match(j.items[0].cover, /^\/api\/read\/store\/cover\?s=gutenberg/); assert.equal(j.more, true);
|
||||
assert.ok(seen.includes('GET /se/opds/all?query=alice'), 'the search link in the catalogue is used'); assert.ok(!j.items.some(i => i.ref === '../bad'), 'odd identifiers are dropped');
|
||||
r = await call('/search?q=&source=standardebooks&page=2'); j = await r.json(); assert.deepEqual(j.items.map(i => i.title), ['Page Two'], 'no search words: the newest list, and its next pages');
|
||||
assert.deepEqual(j.items.map(i => i.source), ['gutenberg', 'standardebooks', 'archive', 'gutenberg', 'standardebooks'].slice(0, j.items.length), 'one result from each catalogue in turn'); assert.deepEqual(j.errors, {});
|
||||
const g = j.items.filter(i => i.source === 'gutenberg'); assert.deepEqual(g.map(i => i.ref), ['11', '345'], 'catalogue entries that are not books, and repeats, are left out'); assert.equal(g[0].author, 'Lewis Carroll'); assert.equal(g[1].title, 'Dracula & Co'); assert.match(g[0].cover, /^\/api\/read\/store\/cover\?s=gutenberg/); assert.equal(j.more, true);
|
||||
const se = j.items.filter(i => i.source === 'standardebooks'); assert.equal(se[0].title, 'Dracula alice'); assert.equal(se[0].author, 'Bram Stoker'); assert.equal(se[1].title, 'The Pilgrim’s Progress'); assert.equal(se[0].ref, '/ebooks/bram-stoker/dracula');
|
||||
assert.ok(!j.items.some(i => i.ref === '../bad'), 'odd identifiers are dropped'); assert.ok(!seen.some(x => x.startsWith('/feeds/opds')), 'the members-only feed is never asked for');
|
||||
r = await call('/search?q=&source=gutenberg'); j = await r.json(); assert.ok(seen.some(x => x.includes('sort_order=downloads')), 'no search words: the most downloaded books'); assert.equal(j.items.length, 2);
|
||||
r = await call('/search?q=x&source=gutenberg&page=2'); assert.ok(seen.some(x => x.includes('start_index=26')), 'later pages ask for the next 25'); assert.equal((await r.json()).more, false);
|
||||
r = await call('/search?q=x&source=nope'); j = await r.json(); assert.ok(j.errors.nope, 'a broken catalogue is reported and does not stop the rest');
|
||||
r = await call(j.items ? '/cover?s=gutenberg&u=' + encodeURIComponent(B + '/img/11.gif') : ''); assert.equal(r.headers.get('content-type'), 'image/gif');
|
||||
r = await call('/cover?s=gutenberg&u=' + encodeURIComponent(B + '/img/11.gif')); assert.equal(r.headers.get('content-type'), 'image/gif');
|
||||
assert.equal((await call('/cover?s=gutenberg&u=' + encodeURIComponent('http://localhost:' + port + '/img/11.gif'))).status, 502, 'covers only come from the catalogue\'s own site');
|
||||
// adding
|
||||
r = await post('/add', { source: 'gutenberg', ref: '11', title: 'Alice', author: 'Lewis Carroll', cover: B + '/img/11.gif' }); let book = await r.json(); assert.equal(r.status, 200); assert.equal(book.ext, 'epub'); assert.equal(book.author, 'Lewis Carroll'); assert.equal(book.cover, true);
|
||||
r = await post('/add', { source: 'gutenberg', ref: '11', title: 'Alice', author: 'Lewis Carroll', cover: B + '/cache/epub/11/pg11.cover.medium.jpg' }); let book = await r.json(); assert.equal(r.status, 200); assert.equal(book.ext, 'epub'); assert.equal(book.author, 'Lewis Carroll'); assert.equal(book.cover, true);
|
||||
assert.ok(seen.includes('/ebooks/11.epub3.images'), 'the EPUB with pictures is fetched');
|
||||
r = await post('/add', { source: 'gutenberg', ref: '11', title: 'Alice' }); assert.equal((await r.json()).duplicate, true, 'adding the same book twice does not store it twice');
|
||||
assert.equal((await (await fetch(base.replace('/store', '/list'))).json()).length, 1);
|
||||
assert.equal((await post('/add', { source: 'gutenberg', ref: '11' }, { 'x-user': 'b' })).status, 200); assert.equal((await (await fetch(base.replace('/store', '/list'), { headers: { 'x-user': 'b' } })).json()).length, 1, "each person's shelf is their own");
|
||||
r = await post('/add', { source: 'archive', ref: 'moby1', title: 'Moby', author: 'Melville' }); assert.equal((await r.json()).title, 'Moby'); assert.ok(seen.includes('GET /ia/download/moby1/moby1.epub'), 'the EPUB is chosen over the PDF');
|
||||
r = await post('/add', { source: 'standardebooks', ref: '/ebooks/bram-stoker/dracula', title: 'Dracula', author: 'Bram Stoker' }); assert.equal(r.status, 200, 'the real EPUB is fetched, not the "download has started" page'); assert.ok(seen.some(x => x.includes('bram-stoker_dracula.epub?source=download')) && !seen.some(x => x.includes('kepub') && x.includes('source')), 'the plain EPUB (not the Kobo or advanced one) is chosen');
|
||||
r = await post('/add', { source: 'standardebooks', ref: '/ebooks/../etc/passwd' }); assert.equal(r.status, 400);
|
||||
r = await post('/add', { source: 'archive', ref: 'moby1', title: 'Moby', author: 'Melville' }); assert.equal((await r.json()).title, 'Moby'); assert.ok(seen.includes('/ia/download/moby1/moby1.epub'), 'the EPUB is chosen over the PDF');
|
||||
r = await post('/add', { source: 'archive', ref: 'locked' }); assert.equal(r.status, 403); assert.match((await r.json()).error, /DRM/);
|
||||
r = await post('/add', { source: 'gutenberg', ref: '66' }); assert.equal(r.status, 502, 'a download that redirects off the site is refused'); assert.ok(!seen.some(x => x.includes('/secret')));
|
||||
r = await post('/add', { source: 'gutenberg', ref: '../x' }); assert.equal(r.status, 400);
|
||||
r = await post('/add', { source: 'standardebooks', ref: 'http://localhost:' + port + '/se/dl/dracula.epub' }); assert.equal(r.status, 400, 'a catalogue book must live on the catalogue\'s own address');
|
||||
r = await post('/add', { source: 'standardebooks', ref: B + '/se/dl/dracula.epub', title: 'Dracula', format: 'epub' }); assert.equal(r.status, 200);
|
||||
// custom catalogue
|
||||
assert.equal((await post('/sources', { name: 'Mine', url: B + '/cal/opds' }, { 'x-user': 'b' })).status, 403, 'only an admin adds catalogues');
|
||||
assert.equal((await post('/sources', { name: 'Bad', url: B + '/cal/opds', user: 'reader', pass: 'wrong' })).status, 502, 'a catalogue that refuses the sign-in is not added');
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Checks the visualizer's pure logic and that it is wired into the pages (the pictures themselves are checked in a browser).
|
||||
const fs = require('fs'), vm = require('vm'), assert = require('assert');
|
||||
let n = 0; const ok = (c, m) => { assert(c, m); n++; };
|
||||
const src = fs.readFileSync(__dirname + '/../public/visualizer.js', 'utf8');
|
||||
new vm.Script(src); ok(true, 'parses');
|
||||
const ctx = { window: {}, document: { createElement: () => ({ getContext: () => null }), addEventListener() {}, body: {} }, localStorage: { getItem: () => null, setItem() {} }, performance: { now: () => 0 }, requestAnimationFrame() {}, addEventListener() {}, navigator: {}, console };
|
||||
vm.createContext(ctx); vm.runInContext(src + '\nthis.vizKind = vizKind; this.VIZ_MODES = VIZ_MODES;', ctx);
|
||||
for (const k of ['pod:1', 'ab:2', 'lv:3', 'vbv:4']) ok(ctx.vizKind({ key: k }) === 'speech', k + ' is speech');
|
||||
for (const k of ['12345', 'radio:journeyfm']) ok(ctx.vizKind({ key: k }) === 'music', k + ' is music');
|
||||
ok(ctx.VIZ_MODES.length === 6 && ctx.VIZ_MODES[0][0] === 'classic', 'six looks, Classic first');
|
||||
const root = __dirname + '/../public/', rd = f => fs.readFileSync(root + f, 'utf8');
|
||||
ok(rd('index.html').includes('/visualizer.js'), 'script is loaded');
|
||||
ok(rd('player.js').includes("id=\"npv\"") && rd('player.js').includes("id=\"cnv\""), 'buttons in the player and the Music card');
|
||||
ok(rd('player.js').includes('vizTrackChanged') && rd('radio-view.js').includes('vizTrackChanged'), 'follows track changes');
|
||||
console.log('visualizer: ' + n + ' checks passed');
|
||||
@@ -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; };
|
||||
|
||||
@@ -67,6 +67,8 @@ const publicStudy = s => ({ ...s, cover: coverUrl(s.cover), lessons: s.lessons.m
|
||||
const notesOf = (slug, n, lessonUrl) => cached(`notes:${slug}:${n}`, 6 * 3600e3, async () => {
|
||||
const h = await page(lessonUrl); const pdfs = [...h.matchAll(/https?:\/\/[^"'\s<>]+\.pdf/gi)].map(m => unent(m[0])).filter(allowed); return pdfs[0] || '';
|
||||
});
|
||||
const audioUrl = async (slug, n) => { const l = (await feed(slug)).lessons[n]; if (!l) throw fail(404, 'No such lesson'); const a = l.audio || (l.link && allowed(l.link) ? await lessonAudio(l.link) : ''); if (!a) throw fail(404, 'This lesson has no audio'); return a; };
|
||||
const notesUrl = async (slug, n) => { const l = (await feed(slug)).lessons[n]; if (!l) throw fail(404, 'No such lesson'); const pdf = l.link && allowed(l.link) ? await notesOf(slug, n, l.link) : ''; if (!pdf) throw fail(404, 'This lesson has no handout'); return pdf; };
|
||||
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } };
|
||||
|
||||
async function handle(req, res) {
|
||||
@@ -80,13 +82,11 @@ async function handle(req, res) {
|
||||
}
|
||||
if ((m = /^\/study\/([\w-]+)$/.exec(p)) && req.method === 'GET') return json(res, 200, publicStudy(await feed(m[1])));
|
||||
if ((m = /^\/audio\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording is on the ministry's media server; the browser is sent straight there (it supports seeking)
|
||||
const l = (await feed(m[1])).lessons[+m[2]]; if (!l) throw fail(404, 'No such lesson');
|
||||
const audio = l.audio || (l.link && allowed(l.link) ? await lessonAudio(l.link) : ''); if (!audio) throw fail(404, 'This lesson has no audio');
|
||||
const audio = await audioUrl(m[1], +m[2]);
|
||||
res.writeHead(302, { location: audio, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||
}
|
||||
if ((m = /^\/notes\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') {
|
||||
const l = (await feed(m[1])).lessons[+m[2]]; if (!l) throw fail(404, 'No such lesson');
|
||||
const pdf = l.link && allowed(l.link) ? await notesOf(m[1], m[2], l.link) : ''; if (!pdf) throw fail(404, 'This lesson has no handout');
|
||||
const pdf = await notesUrl(m[1], +m[2]);
|
||||
res.writeHead(302, { location: pdf, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||
}
|
||||
if (p === '/cover' && req.method === 'GET') {
|
||||
@@ -110,4 +110,4 @@ async function handle(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handle };
|
||||
module.exports = { handle, studyInfo: feed, audioUrl, notesUrl, allowed, UA };
|
||||
Reference in new issue
Block a user