25 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 d0e4e82e0a Signal and Chat photos open in an in-app viewer (X, tap outside, Escape or Back to return to the chat)
build-container / build (push) Successful in 3m5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:53:26 -04:00
nmemmertandClaude Sonnet 5.5 c4b830dd43 Admin health: push devices, pushes sent, expired devices and the last failure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:52:05 -04:00
nmemmertandClaude Sonnet 5.5 551f970721 Polish: npm test runs every test file, two silent failures now log, Loading placeholders shimmer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:43:35 -04:00
nmemmertandClaude Sonnet 5.5 d173555398 Player: a song stuck on Buffering is reloaded after 10s, switched to the converted stream after 20s, and skipped after 30s; a play() that fails for any reason now recovers
build-container / build (push) Successful in 3m2s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 10:58:27 -04:00
nmemmertandClaude Sonnet 5.5 4e26bea455 My Store: select books and add them to chosen people's shelves
build-container / build (push) Successful in 3m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 09:50:30 -04:00
nmemmertandClaude Sonnet 5.5 3376499805 My Store: Add many books, with title, author, description and cover read out of each EPUB
build-container / build (push) Successful in 3m32s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 09:48:04 -04:00
nmemmertandClaude Sonnet 5.5 272760c321 iPad: no text selection or image menu on press-and-hold, no accidental pinch or double-tap zoom, fields stay at 16px so focus does not zoom
build-container / build (push) Successful in 2m55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 08:37:27 -04:00
nmemmertandClaude Sonnet 5.5 bbe32bae83 Music: on wide screens the Queue button docks an Up next panel down the right side that stays open while browsing
build-container / build (push) Successful in 3m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 08:15:43 -04:00
nmemmertandClaude Sonnet 5.5 1293ff0673 Bottom player bars: add a visible playhead you can grab and drag, music and video
build-container / build (push) Successful in 3m2s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 08:04:43 -04:00
nmemmertandClaude Sonnet 5.5 94ad5a8e1d Music: close button no longer offsets the Now Playing art, Family playlists get a 2x2 art cover, phone tabs fit one row with a More menu
build-container / build (push) Successful in 3m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 07:58:51 -04:00
Claude e248bac143 Merge music-iphone
build-container / build (push) Successful in 2m59s
2026-10-28 02:57:53 +00:00
Claude d025e250fe Music on iPhone: keep 3-5 songs ready (size-capped), skip a song that will not load instead of sticking on Buffering, tidy the album buttons, lock-screen art in more sizes, and accept the lookups and song-cache settings on the server
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-28 02:56:18 +00:00
Claude 1a797c972e Merge video-bar
build-container / build (push) Successful in 3m15s
2026-10-27 23:26:06 +00:00
Claude 2b4da8d9a2 Video full screen: a tap while the bar is hidden brings it back (the touch no longer wakes and then hides it again)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-27 23:24:21 +00:00
Claude 139ecf4ab9 Merge board-chores
build-container / build (push) Successful in 2m53s
2026-10-27 23:13:02 +00:00
Claude 45c4dbe120 Family board: look for new chores every 15 seconds instead of every minute
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-27 23:10:12 +00:00
Claude b0d6473e4d Visualizer test: recognise the player's new button layout
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-27 23:08:19 +00:00
Claude 0ae68d6842 Family board: tapping a chore assigned to several kids opens the right kid's chore, and each kid has their own column
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
2026-10-27 23:05:19 +00:00
nmemmertandClaude Sonnet 5.5 fd5812c5c2 Player: keep retrying after a lost connection, resume songs cut short, recover on tab return, never let a screen error block playback
build-container / build (push) Successful in 3m30s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:27:53 -04:00
nmemmertandClaude Sonnet 5.5 8b77bacb7a Video full screen: the bottom bar hides after a few idle seconds and returns on movement
build-container / build (push) Successful in 4m16s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:12:49 -04:00
nmemmertandClaude Sonnet 5.5 ee4674cdad Video: controls move from the video box to the bottom bar, in full screen too
build-container / build (push) Successful in 3m35s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 17:04:40 -04:00
nmemmertandClaude Sonnet 5.5 1c6fff51bb Player bar: look up vizOpen/sleepSheet when tapped, so the player test (which doesn't load those files) passes
build-container / build (push) Successful in 3m49s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 16:49:02 -04:00
nmemmertandClaude Sonnet 5.5 3db4e4aa1e Music: drop the duplicate player card, add Visuals/Queue/Sleep to the bottom bar, redesign Now Playing sheet, add full-screen view, show queue when no lyrics
build-container / build (push) Failing after 52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 16:45:55 -04:00
nmemmertandClaude Sonnet 5.5 4f482a1e1a Move a Plex playlist to NE Hub as a Family playlist, with the option to remove the Plex copy
build-container / build (push) Successful in 3m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 15:44:07 -04:00
nmemmertandClaude Sonnet 5.5 8b295dddef Merge branch 'signal-inside-chat'
build-container / build (push) Successful in 3m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 14:02:45 -04:00
51 changed files with 560 additions and 153 deletions

No files matched your search

+7
View File
@@ -112,6 +112,11 @@ It listens to the sound the browser is playing. Where it cannot (iPhone Safari,
- **Continue reading:** Home lists the ebooks you have started, with progress, next to Continue watching and Continue listening.
- **Privacy:** Settings, Connections has a switch for looking up lyrics and song covers online. Off keeps the artist and title from LRCLIB and iTunes; radio then shows the station logo only.
## Music on a phone
- **Songs kept ready:** the song playing and the next few (3 to 5, set in Settings under Connections) are saved on the device while you listen, up to about 120 MB, so changing songs is instant and a weak signal does not stop the music.
- **A song that will not play** is tried once through Plex's converter, then skipped with a message, instead of leaving the player stuck on "Buffering".
## 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
@@ -158,6 +163,8 @@ One **Store** tab (turn it on per person) with sections for **Books** (the free
**My Store** is your own catalogue, for everyone who has the Store tab. The admin adds items with **Add**: upload files, or copy a file from their Seafile (**From Files**), with an optional cover picture.
- **Books** (EPUB, PDF, MOBI, AZW3, DOCX): *Add to my library* puts a copy on that person's shelf, or download it.
- **Add many books** (admin): pick a whole batch of book files at once. Each one is uploaded on its own and the server reads the **title, author, description and cover picture out of the EPUB itself** (`epubmeta.js`, no libraries); a book already in the store (same file) is skipped. PDF, MOBI, AZW3 and DOCX carry little usable metadata, so they are named after the file, with no author or cover. When it finishes it lists any book with no cover or no author so you can fix those with *Edit*.
- **Select and add to people** (admin): tap *Select* in My Store, tick books (or *Select all shown*), then *Add to people…* and tick the people. The books land on each person's own shelf; someone without the Books tab is skipped and a book they already have is left alone.
- **Music** (MP3, M4A, AAC, FLAC, OGG, OPUS, WAV): an album can have many tracks; they play in the hub's player and can be downloaded.
- **Apps**: any files to download (an APK, an installer...), with an optional link and version. The hub does not run them.
- **Sites**: any website as a card. It opens in a new tab, or inside the hub if you tick *Open inside the hub* (some sites refuse to be shown that way).
+46
View File
@@ -0,0 +1,46 @@
// Reads the title, author, description and cover picture out of an EPUB (a zip with an OPF file inside), with no libraries.
const zlib = require('zlib'), path = require('path');
const MAX_PART = 8 * 1024 * 1024; // the biggest piece of an EPUB this reads (the OPF and the cover picture)
const IMAGE = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };
/** The entries of a zip: name -> where to find it. */
function entries(buf) {
let e = -1; for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i--) if (buf.readUInt32LE(i) === 0x06054b50) { e = i; break; }
if (e < 0) throw new Error('Not a zip');
const n = buf.readUInt16LE(e + 10); let p = buf.readUInt32LE(e + 16); const out = new Map();
for (let k = 0; k < n && p + 46 <= buf.length && buf.readUInt32LE(p) === 0x02014b50; k++) {
const method = buf.readUInt16LE(p + 10), csize = buf.readUInt32LE(p + 20), usize = buf.readUInt32LE(p + 24), nl = buf.readUInt16LE(p + 28), xl = buf.readUInt16LE(p + 30), cl = buf.readUInt16LE(p + 32), off = buf.readUInt32LE(p + 42);
out.set(buf.toString('utf8', p + 46, p + 46 + nl), { method, csize, usize, off }); p += 46 + nl + xl + cl;
}
return out;
}
function read(buf, ent) {
if (!ent || ent.usize > MAX_PART || buf.readUInt32LE(ent.off) !== 0x04034b50) return null;
const s = ent.off + 30 + buf.readUInt16LE(ent.off + 26) + buf.readUInt16LE(ent.off + 28), raw = buf.subarray(s, s + ent.csize);
return ent.method === 0 ? raw : ent.method === 8 ? zlib.inflateRawSync(raw, { maxOutputLength: MAX_PART }) : null;
}
const unxml = s => String(s).replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, ' ').replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (_, c) =>
({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" })[c.toLowerCase()] || String.fromCodePoint(c[1].toLowerCase() === 'x' ? parseInt(c.slice(2), 16) : +c.slice(1))).replace(/\s+/g, ' ').trim();
const tagsOf = (xml, name) => [...xml.matchAll(new RegExp(`<(?:\\w+:)?${name}\\b([^>]*?)(?:/>|>([\\s\\S]*?)</(?:\\w+:)?${name}>)`, 'gi'))].map(m => ({ attrs: m[1] || '', text: m[2] == null ? '' : unxml(m[2]) }));
const attr = (attrs, name) => { const m = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i').exec(attrs); return m ? (m[2] != null ? m[2] : m[3]) : ''; };
const resolve = (base, href) => { try { href = decodeURIComponent(href); } catch {} return path.posix.normalize(path.posix.join(path.posix.dirname(base), href.split('#')[0])).replace(/^\/+/, ''); };
/** { title, author, description, cover: { type, data } | null } (any part may be missing). Throws if the file is not a readable EPUB. */
function readEpub(buf) {
const z = entries(buf), container = read(buf, z.get('META-INF/container.xml')); if (!container) throw new Error('No container.xml');
const opfPath = attr((tagsOf(container.toString('utf8'), 'rootfile')[0] || {}).attrs || '', 'full-path'); if (!opfPath) throw new Error('No package file');
const opf = (read(buf, z.get(opfPath)) || Buffer.alloc(0)).toString('utf8');
const meta = { title: (tagsOf(opf, 'title')[0] || {}).text || '', author: tagsOf(opf, 'creator').map(c => c.text).filter(Boolean).join(', '), description: ((tagsOf(opf, 'description')[0] || {}).text || '').slice(0, 2000), cover: null };
// the cover picture: the item the package calls the cover-image, else the one <meta name="cover"> points at, else an image named "cover"
const items = tagsOf(opf, 'item').map(i => ({ id: attr(i.attrs, 'id'), href: attr(i.attrs, 'href'), type: attr(i.attrs, 'media-type').toLowerCase(), props: attr(i.attrs, 'properties') }));
const metaCover = tagsOf(opf, 'meta').find(m => attr(m.attrs, 'name').toLowerCase() === 'cover'), cid = metaCover && attr(metaCover.attrs, 'content');
const pick = items.find(i => /\bcover-image\b/.test(i.props)) || items.find(i => cid && i.id === cid) || items.find(i => /^image\//.test(i.type) && /cover/i.test(i.id + ' ' + i.href));
if (pick) {
const data = read(buf, z.get(resolve(opfPath, pick.href))), ext = path.extname(pick.href).slice(1).toLowerCase(), type = /^image\/(jpeg|png|gif|webp)$/.test(pick.type) ? pick.type : IMAGE[ext];
if (data && type && data.length > 500) meta.cover = { type, data };
}
return meta;
}
module.exports = { readEpub };
+1 -1
View File
@@ -49,7 +49,7 @@ const fail = (status, message) => Object.assign(new Error(message), { status });
const accFile = () => path.join(users.dir(), 'mail-accounts.json');
const legacyFile = () => path.join(users.dir(), 'mail.json'); // one Outlook.com account, from before there were several
const accounts = () => {
try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch {}
try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch (e) { if (e.code !== 'ENOENT') console.warn('[mail] could not read the saved mail accounts (they will look empty until this is fixed):', e.message); }
try { // migrate the old single-account file
const old = vault.unseal(JSON.parse(fs.readFileSync(legacyFile(), 'utf8')).data);
if (old) { const list = [{ id: crypto.randomBytes(3).toString('hex'), type: 'outlook', ...old }]; saveAccounts(list); fs.rmSync(legacyFile(), { force: true }); return list; }
+34
View File
@@ -7,11 +7,13 @@ const { pipeline } = require('stream/promises');
const atomicWrite = require('./atomic');
const users = require('./users'), settings = require('./settings'), books = require('./books');
const { readBody } = require('./body');
const { readEpub } = require('./epubmeta');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const ROOT = () => path.join(DATA_DIR, 'mystore'), ITEMS = () => path.join(ROOT(), 'items.json'), FILES = () => path.join(ROOT(), 'files');
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const MAX_FILE = 1024 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_EXT = 2 * 1024 * 1024, MAX_STATE = 256 * 1024, MAX_FILES_PER_ITEM = 200;
const MAX_BOOK_IMPORT = 300 * 1024 * 1024;
const KINDS = ['book', 'music', 'app', 'extension', 'link'];
const PERMS = ['tasks.read', 'tasks.write', 'contacts.read', 'contacts.write', 'calendar.read', 'calendar.write', 'notes.read', 'notes.write', 'recipes.read', 'music.read']; // what an extension may ask the person for
const BOOK_EXT = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'docx'];
@@ -107,6 +109,21 @@ async function handle(req, res) {
if (it.kind === 'link') it.embed = !!b.embed;
if (!old) { if (l.length >= 2000) throw fail(400, 'That is plenty of items'); l.push(it); } saveItems(l); return json(res, 200, view(it));
}
if (p === '/import' && req.method === 'POST') { // bulk add: the body is one book file (its name is in ?name=); the title, author, description and cover come out of the book itself (admin)
needAdmin(me); const name = safeName(u.searchParams.get('name')), ext = extOf(name);
if (!BOOK_EXT.includes(ext)) throw fail(400, 'A book must be an EPUB, PDF, MOBI, AZW3 or DOCX file');
const l = loadItems(); if (l.length >= 2000) throw fail(400, 'That is plenty of items');
const it = { id: newId(), kind: 'book', files: [], cover: false, added: Date.now() }, fid = newId(), file = fileOn(it, fid);
const size = await receive(req, file, MAX_BOOK_IMPORT), buf = fs.readFileSync(file), hash = crypto.createHash('sha1').update(buf).digest('hex');
const dup = l.find(x => x.kind === 'book' && x.files.some(f => f.hash === hash));
if (dup) { fs.rmSync(file, { force: true }); return json(res, 200, { duplicate: true, item: view(dup) }); }
let meta = {}; if (ext === 'epub') { try { meta = readEpub(buf); } catch { meta = {}; } }
const plain = path.basename(name, '.' + ext).replace(/_+/g, ' ').trim();
Object.assign(it, { title: str(meta.title, 120) || str(plain, 120) || 'Untitled', creator: str(meta.author, 120), description: str(meta.description, 2000), version: '', url: '', updated: Date.now() });
it.files.push({ id: fid, name, size, ext, hash });
if (meta.cover && meta.cover.data.length <= MAX_COVER) { fs.writeFileSync(coverOn(it), meta.cover.data, { mode: 0o600 }); it.cover = meta.cover.type; }
l.push(it); saveItems(l); return json(res, 200, { item: view(it), noAuthor: !it.creator, noCover: !it.cover });
}
if ((m = /^\/items\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') {
needAdmin(me); const l = loadItems(), it = l.find(x => x.id === m[1]); if (!it) throw fail(404, 'Item not found');
for (const f of it.files) fs.rmSync(fileOn(it, f.id), { force: true }); fs.rmSync(coverOn(it), { force: true }); saveItems(l.filter(x => x !== it)); return json(res, 200, { ok: true });
@@ -137,6 +154,23 @@ async function handle(req, res) {
if (req.method === 'DELETE') { needAdmin(me); fs.rm(coverOn(it), { force: true }, () => {}); it.cover = false; saveItems(l); return json(res, 200, view(it)); }
if (req.method === 'GET') { if (!it.cover) throw fail(404, 'No cover'); return send(req, res, coverOn(it), it.cover, 'cover', false); }
}
if (p === '/people' && req.method === 'GET') { needAdmin(me); return json(res, 200, users.all().filter(u => !u.disabled).map(u => ({ id: u.id, name: u.name, books: users.can(u, 'read') }))); }
if (p === '/assign' && req.method === 'POST') { // put the chosen books on the chosen people's shelves (admin). A book they already have is left alone.
needAdmin(me); const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = loadItems();
const its = [...new Set(Array.isArray(b.items) ? b.items : [])].map(id => l.find(x => x.id === id)).filter(x => x && x.kind === 'book' && x.files[0]);
const who = [...new Set(Array.isArray(b.users) ? b.users : [])].map(id => users.get(id)).filter(u => u && !u.disabled);
if (!its.length) throw fail(400, 'Choose at least one book'); if (!who.length) throw fail(400, 'Choose at least one person');
const out = [];
for (const u of who) {
const r = { id: u.id, name: u.name, added: 0, had: 0, failed: 0, skipped: !users.can(u, 'read') };
if (!r.skipped) await users.runAs(u, async () => { for (const it of its) {
try { const f = it.files[0], buf = fs.readFileSync(fileOn(it, f.id)); let cover; if (it.cover) { try { cover = { type: it.cover, data: fs.readFileSync(coverOn(it)) }; } catch {} }
books.addBook(buf, f.ext, { title: it.title, author: it.creator }, cover).duplicate ? r.had++ : r.added++; } catch { r.failed++; }
} });
out.push(r);
}
return json(res, 200, { books: its.length, people: out });
}
if ((m = /^\/items\/([0-9a-f]{12})\/to-library$/.exec(p)) && req.method === 'POST') { // a book goes onto the person's own shelf
if (!users.can(me, 'read')) throw fail(403, 'You do not have the Books tab');
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'book' || !it.files[0]) throw fail(404, 'That is not a book');
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "for t in smoke frontend tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
"test": "node tools/run-tests.js",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
+4 -1
View File
@@ -11,7 +11,7 @@ const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups', 'music_feat_pls']);
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', 'lookups', 'music_feat_pls', 'song_cache']);
const outbox = new Map(); let outTimer = null, warned = false;
function flushSettings() {
outTimer = null;
@@ -55,6 +55,9 @@ async function loadSettings() {
const ICONS = {
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
queue: '<path d="M4 6h12M4 11h12M4 16h7M15 14v6l5-3z"/>',
visuals: '<path d="M5 10v4M9 6v12M13 9v6M17 4v16M21 11v2"/>',
moon: '<path d="M20 14.5A8 8 0 019.5 4 8 8 0 1020 14.5z"/>',
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
+2 -2
View File
@@ -61,7 +61,7 @@ const abLoadProg = async () => { try { for (const [id, p] of Object.entries(awai
async function viewBooks(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
const [data] = await Promise.all([abApi('/books'), abLoadProg(), lvPrime()]);
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
@@ -105,7 +105,7 @@ function abSheet(b) {
if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); };
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
el.querySelector('#abc').onclick = async () => {
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
const ch = await abChapters(id);
box.innerHTML = ch.length ? ch.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${abFmt(c.start)}</small></span></button>`).join('') : '<p class="empty">This book has no chapter list.</p>';
box.querySelectorAll('button[data-i]').forEach(x => x.onclick = () => go(ch[x.dataset.i].start));
+1 -1
View File
@@ -63,7 +63,7 @@ function bbPaint(root) { // highlights, notes and picked verses, on top of the t
}
async function viewBible(args = []) {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Bible</h1><p class="empty">Loading the Bible…</p></div>';
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Bible</h1><p class="empty loading" role="status">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 {}
+6 -6
View File
@@ -19,9 +19,9 @@ function draw() {
$('#tiles').innerHTML = d.people.map(p => `<button class="tile" data-id="${esc(p.id)}"><span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><b>${esc(p.name.split(' ')[0])}</b><small>${p.kid ? '<span class="kid">KID</span>' : ''}</small></button>`).join('');
$('#tiles').querySelectorAll('.tile').forEach(b => b.onclick = () => pick(d.people.find(p => p.id === b.dataset.id)));
const day = (label, ds) => { const l = d.events.filter(e => e.allDay ? e.start <= ds && e.end > ds : ymd(new Date(e.start)) === ds).sort((a, b) => (b.allDay - a.allDay) || a.start - b.start); return `<small class="sub">${label}</small>` + (l.map(e => `<div class="ln"><span class="dot" style="background:${esc(e.color || 'var(--accent)')}"></span><span class="g">${esc(e.title)}${e.by ? ` <small>${esc(e.by)}</small>` : ''}</span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || none('Nothing')); };
const pill = (c, cls) => `<button class="pill ${cls || ''}" data-c="${esc(c.chore)}">${esc(c.title)}${c.points ? ` <small>${c.points} pts</small>` : ''}</button>`;
const pool = d.chores.filter(c => c.claimable && !c.done), per = {}; d.chores.filter(c => !(c.claimable && !c.done)).forEach(c => { (per[c.userName] = per[c.userName] || []).push(c); });
const SHOW = 6, chores = (pool.length ? `<button class="pill pool" id="poolall" style="width:100%;justify-content:space-between;font-size:17px"><span>Pick a chore: ${pool.length} up for grabs</span><span>See all ›</span></button>` : '') + '<div class="chcols">' + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length, left = l.filter(c => !c.done); return `<div class="chp"><div class="top"><b>${esc(k)}</b><small class="sub">${dn} of ${l.length}</small></div><div class="bar2"><i style="width:${Math.round(dn / l.length * 100)}%"></i></div><div class="chl">${left.map(c => pill(c)).join('')}${left.length ? '' : '<small class="sub">All done 🎉</small>'}</div></div>`; }).join('') + '</div>';
const pill = (c, cls) => `<button class="pill ${cls || ''}" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">${esc(c.title)}${c.points ? ` <small>${c.points} pts</small>` : ''}</button>`;
const pool = d.chores.filter(c => c.claimable && !c.done), per = {}; d.chores.filter(c => !(c.claimable && !c.done)).forEach(c => { (per[c.user || c.userName] = per[c.user || c.userName] || []).push(c); });
const SHOW = 6, chores = (pool.length ? `<button class="pill pool" id="poolall" style="width:100%;justify-content:space-between;font-size:17px"><span>Pick a chore: ${pool.length} up for grabs</span><span>See all ›</span></button>` : '') + '<div class="chcols">' + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length, left = l.filter(c => !c.done); return `<div class="chp"><div class="top"><b>${esc(l[0].userName)}</b><small class="sub">${dn} of ${l.length}</small></div><div class="bar2"><i style="width:${Math.round(dn / l.length * 100)}%"></i></div><div class="chl">${left.map(c => pill(c)).join('')}${left.length ? '' : '<small class="sub">All done 🎉</small>'}</div></div>`; }).join('') + '</div>';
$('#grid').innerHTML = card('Chores today', chores || none('No chores today'), '', 'wide')
+ card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1), 'event')
+ card('This week', weekCard(d.week)) + card('Shopping list', d.shopping.map(i => `<div class="ln"><span class="g">${esc(i.text)}</span>${i.by ? `<small>${esc(i.by)}</small>` : ''}</div>`).join('') || none('Nothing to buy'), 'shopping')
@@ -31,9 +31,9 @@ function draw() {
}
function poolAll() {
const list = data.chores.filter(c => c.claimable && !c.done), { box, close } = sheet(`<h3 style="margin:0 0 4px">Up for grabs</h3><p class="sub">${list.length} chores. Tap one to pick it</p><div style="text-align:left;max-height:58dvh;overflow:auto">${list.map(c => `<button class="pill pool" data-c="${esc(c.chore)}" style="width:100%;justify-content:space-between;font-size:18px;padding:12px 14px;margin:4px 0"><span>${esc(c.title)}</span>${c.points ? `<small>${c.points} pts</small>` : ''}</button>`).join('')}</div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Close</button></p>`);
box.querySelector('#no').onclick = close; box.querySelectorAll('.pill[data-c]').forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c)));
box.querySelector('#no').onclick = close; box.querySelectorAll('.pill[data-c]').forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c && (c.user || '') === (b.dataset.u || ''))));
}
function wireChores() { document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); const all = document.getElementById('poolall'); if (all) all.onclick = poolAll; document.querySelectorAll(".pill[data-c]").forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c))); }
function wireChores() { document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); const all = document.getElementById('poolall'); if (all) all.onclick = poolAll; document.querySelectorAll(".pill[data-c]").forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c && (c.user || '') === (b.dataset.u || '')))); }
async function load() { try { const r = await fetch('/api/board?day=' + ymd(new Date())); if (r.status === 403) { location.replace('/login'); return; } data = await r.json(); draw(); } catch {} }
function pick(p) {
@@ -49,7 +49,7 @@ function pick(p) {
paint(); $('#no').onclick = close;
box.querySelectorAll('.pad button').forEach(b => b.onclick = async () => { let k = b.dataset.k; $('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await go(pin); return; } catch (e) { $('#e').textContent = e.message; pin = ''; } } paint(); });
}
clock(); setInterval(clock, 15000); load(); setInterval(() => { if (!document.hidden && $('#ov').hidden) load(); }, 60000);
clock(); setInterval(clock, 15000); load(); setInterval(() => { if (!document.hidden && $('#ov').hidden) load(); }, 15000); // (a chore someone just added shows up within a few seconds)
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
// ---- tap a chore: (if it is up for grabs, choose what you are doing), pick your name, PIN if you are not a child, then it happens as you
+2
View File
@@ -1,5 +1,7 @@
'use strict';
// ---- Boot
// Stop the accidental pinch zoom on iPads (Safari ignores user-scalable=no). Double-tap zoom is stopped in the CSS (touch-action: manipulation).
for (const ev of ['gesturestart', 'gesturechange', 'gestureend']) document.addEventListener(ev, e => e.preventDefault(), { passive: false });
(async () => {
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
const loadedBuild = cfg.build;
+1 -1
View File
@@ -57,7 +57,7 @@ async function viewCalendar() {
const views = `<div class="sgrp" role="tablist" aria-label="Calendar view">${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" role="tab" data-v="${k}" aria-selected="${k === mode}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>`
+ (mode === 'agenda' ? `<select id="crange" class="psel" aria-label="How far ahead">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<option value="${n}" ${n === store.get('cal_range', 7) ? 'selected' : ''}>${l}</option>`).join('')}</select>` : '');
v.innerHTML = `<div class="wrap">${plannerHead('calendar', `<button class="btn" id="ne">${icon('cal')}Add event</button>`, views)}
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
<div id="cbody"><p class="empty loading" role="status">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
$('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
if ($('#crange')) $('#crange').onchange = e => { store.set('cal_range', +e.target.value); viewCalendar(); };
v.querySelectorAll('.sgrp a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
+1 -1
View File
@@ -36,7 +36,7 @@ function chEvent(ev) {
function viewChat(args = []) { const id = args[0] ? decodeURIComponent(args[0]) : null; ch.curSig = id && id.startsWith('sg:') ? id.slice(3) : null; ch.cur = ch.curSig ? null : id; if (!ch.curSig && typeof sg === 'object') sg.inChat = false; return chMount($('#view')); }
async function chMount(root) {
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty">Loading…</p></div>';
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty loading" role="status">Loading…</p></div>';
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur || ch.curSig ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
+1 -1
View File
@@ -36,7 +36,7 @@ function crPickGoal(uid) {
const crRefresh = () => location.hash.startsWith('#/chores') ? viewChores() : (cr.afterEdit && cr.afterEdit());
async function viewChores() {
const v = $('#view'); cr.day = cr.day || crYmd(new Date());
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Chores</h1><p class="empty loading" role="status">Loading…</p></div>';
try { cr.wk = cr.wk || crYmd(new Date()); [cr.data, cr.week] = await Promise.all([crApi('/?date=' + cr.day), crApi('/week?date=' + cr.wk)]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Chores</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/chores')) return;
const d = cr.data, me = d.me.id, mine = d.board.filter(i => i.user === me), anyone = d.board.filter(i => i.user === null), others = d.people.filter(p => p.id !== me);
+1 -1
View File
@@ -13,7 +13,7 @@ const ctFind = (l, address) => { const a = String(address || '').toLowerCase();
async function viewContacts(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Contacts</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Contacts</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
const l = await ctLoad(true); if (!location.hash.startsWith('#/contacts')) return;
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Contacts</h1><span class="pspace grow"></span><button class="btn ghost" id="ctmore" aria-label="More">${icon('more')}</button><button class="btn" id="ctnew">${icon('plus')}New</button></div>
+1 -1
View File
@@ -1,7 +1,7 @@
'use strict';
// ---- Family: one start page for the household: the announcement, today's events, chores, the shopping list, shared notes, chats and tasks. Each card loads on its own and
// only shows if the person has that part of the hub, so one slow or missing piece never blanks the page.
const fmCard = (id, title, href, sub) => `<section class="fmcard" id="${id}"><div class="fmh"><h2>${esc(title)}</h2>${href ? `<a href="${href}" class="fmmore">${esc(sub || 'Open')}</a>` : ''}</div><div class="fmb"><p class="empty" style="margin:6px 0">Loading…</p></div></section>`;
const fmCard = (id, title, href, sub) => `<section class="fmcard" id="${id}"><div class="fmh"><h2>${esc(title)}</h2>${href ? `<a href="${href}" class="fmmore">${esc(sub || 'Open')}</a>` : ''}</div><div class="fmb"><p class="empty loading" style="margin:6px 0" role="status">Loading…</p></div></section>`;
const fmBody = id => document.querySelector('#' + id + ' .fmb');
const fmErr = (id, e) => { const b = fmBody(id); if (b) b.innerHTML = `<p class="sub" style="margin:6px 0">${esc(e && e.message || 'Could not load this')}</p>`; };
+1 -1
View File
@@ -2,7 +2,7 @@
// Uses sf, fmtSize, openSheet, confirmSheet, toast, esc and FORM from app.js.
async function fileHistory(repo, path, name, readOnly) {
const base = `/api2/repos/${encodeURIComponent(repo)}/file`;
const el = openSheet('Version history', name, '<p class="empty">Loading…</p>');
const el = openSheet('Version history', name, '<p class="empty loading" role="status">Loading…</p>');
el.querySelector('.sheet').classList.add('wide');
try {
let commits = [], next = '';
+3 -3
View File
@@ -83,7 +83,7 @@ async function offerUndo(repo, dir, items, afterRestore) {
} catch { return false; /* no undo available */ }
}
async function recycleBin(repo, dir) {
const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '<p class="empty">Loading…</p>');
const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '<p class="empty loading" role="status">Loading…</p>');
el.querySelector('.sheet').classList.add('wide');
try {
const list = (await trashList(repo, dir || '/', 100)).sort((a, b) => trashWhen(b.deleted_time) - trashWhen(a.deleted_time));
@@ -143,7 +143,7 @@ async function zipDownload(repo, dir, names) {
// ---- Share links with a password and an expiry, and the ones that already exist
async function shareSheet(repo, path, isDir) {
const p = isDir ? path.replace(/\/?$/, '/') : path, enc = encodeURIComponent, FORMH = { 'Content-Type': 'application/x-www-form-urlencoded' };
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '<p class="empty">Loading…</p>');
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '<p class="empty loading" role="status">Loading…</p>');
const draw = async () => {
let links = []; try { links = (await sf.req(`/api/v2.1/share-links/?repo_id=${enc(repo)}&path=${enc(p)}`)) || []; } catch (e) { if (!document.body.contains(el)) return; }
if (!document.body.contains(el)) return;
@@ -177,7 +177,7 @@ async function shareSheet(repo, path, isDir) {
const TEXT_EXT = new Set('txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore'.split(' '));
async function textPreview(repo, path, name) {
const dir = path.replace(/\/[^/]*$/, '') || '/', ro = libPerm[repo] === 'r', url = (await fileUrl(repo, path)) + '?hubinline=1';
const el = openSheet(name, '', '<p class="empty">Loading…</p>'); el.querySelector('.sheet').classList.add('wide');
const el = openSheet(name, '', '<p class="empty loading" role="status">Loading…</p>'); el.querySelector('.sheet').classList.add('wide');
const dl = `<a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Download</a>`;
try {
const r = await fetch(url); if (!r.ok) throw new Error('Could not open the file (' + r.status + ')');
+4 -4
View File
@@ -42,7 +42,7 @@ function viewFiles([repo, ...parts]) {
if (!sf.token()) return filesLogin();
const dir = '/' + parts.join('/');
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><div class="files ${repo ? 'open' : ''}" id="files">
<div class="libs" id="libs"><p class="empty">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
<div class="libs" id="libs"><p class="empty loading" role="status">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
libsP = loadLibs(repo);
if (repo) loadDir(repo, dir);
}
@@ -229,7 +229,7 @@ async function prefetchDl(repo, path) {
async function loadDir(repo, dir) {
const main = $('#fmain'); if (!main) return;
main.innerHTML = '<p class="empty">Loading…</p>';
main.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
await libsP.catch(() => {});
if (!document.contains(main)) return;
const enc = encodeURIComponent, ro = libPerm[repo] === 'r';
@@ -243,7 +243,7 @@ async function loadDir(repo, dir) {
const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); };
main.innerHTML = `<div id="ftop"></div><div class="crumbs">${crumbs}</div>
<input type="search" id="ff" placeholder="Filter this folder" autocomplete="off" hidden><div id="items"><p class="empty">Loading…</p></div><p class="sub" id="fsum" style="text-align:center"></p>`;
<input type="search" id="ff" placeholder="Filter this folder" autocomplete="off" hidden><div id="items"><p class="empty loading" role="status">Loading…</p></div><p class="sub" id="fsum" style="text-align:center"></p>`;
const go = p => { location.hash = hash(p); };
const drawTop = () => {
@@ -396,7 +396,7 @@ async function loadDir(repo, dir) {
const restore = () => { if (v.scrolled) return; v.scrolled = true; const y = fScroll.get(location.hash); if (y) requestAnimationFrame(() => { const view = $('#view'); if (view) view.scrollTop = y; }); };
if (!quiet) {
if (!v.items.length && saved) { v.items = saved.items; v.stale = 'old'; v.staleWhy = 'Updating…'; redraw(); restore(); } // the saved list at once, the real one right behind it
else $('#items').innerHTML = '<p class="empty">Loading…</p>';
else $('#items').innerHTML = '<p class="empty loading" role="status">Loading…</p>';
}
try {
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
+29
View File
@@ -0,0 +1,29 @@
'use strict';
// Photos in Signal and Chat open in a full-screen viewer inside the app instead of a new tab.
// Close with the X, a tap outside the photo, Escape, or the phone's Back gesture; the chat is right where it was.
(function () {
let box = null, pushed = false;
function close(fromPop) {
if (!box) return; box.remove(); box = null; document.removeEventListener('keydown', onKey, true);
if (pushed && !fromPop) { pushed = false; history.back(); } else pushed = false;
}
const onKey = e => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); } };
function open(src, name) {
close();
box = document.createElement('div'); box.id = 'imgview'; box.setAttribute('role', 'dialog'); box.setAttribute('aria-modal', 'true'); box.setAttribute('aria-label', name || 'Photo');
box.innerHTML = '<button type="button" class="ibtn ivx" aria-label="Close photo"></button><a class="ivsave" download aria-label="Save photo">Save</a><img alt="">';
const x = box.querySelector('.ivx'); x.innerHTML = typeof icon === 'function' ? icon('x') : '✕';
const im = box.querySelector('img'); im.alt = name || 'Photo'; im.src = src;
const sv = box.querySelector('.ivsave'); sv.href = src; if (name) sv.setAttribute('download', name);
x.onclick = () => close();
box.onclick = e => { if (e.target === box) close(); };
document.body.appendChild(box); document.addEventListener('keydown', onKey, true); x.focus({ preventScroll: true });
try { history.pushState({ imgview: 1 }, ''); pushed = true; } catch { /* no history: the X still closes it */ }
}
addEventListener('popstate', () => { if (box) { pushed = false; close(true); } });
document.addEventListener('click', e => {
const im = e.target.closest && e.target.closest('img.sgimg'); if (!im) return;
const a = im.closest('a[href]'); if (!a) return;
e.preventDefault(); open(a.getAttribute('href'), im.alt);
});
})();
+1 -1
View File
@@ -14,7 +14,7 @@ setInterval(() => { if (!document.hidden && typeof ME !== 'undefined' && ME && d
const inboxAgo = t => { const m = Math.round((Date.now() - t) / 6e4); return m < 1 ? 'now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); };
const INBOX_ICON = { chat: ['chat', '#2aa6a0'], remind: ['bell', '#e0742b'], announce: ['bell', '#5b8def'] };
async function inboxOpen() {
const el = openSheet('Notifications', '', '<div id="inboxl"><p class="empty">Loading…</p></div><div class="bar" id="inboxbar" hidden><button class="btn ghost" id="inboxall" style="flex:1;justify-content:center">Mark all read</button><button class="btn ghost" id="inboxclr" style="flex:1;justify-content:center">Clear all</button></div>');
const el = openSheet('Notifications', '', '<div id="inboxl"><p class="empty loading" role="status">Loading…</p></div><div class="bar" id="inboxbar" hidden><button class="btn ghost" id="inboxall" style="flex:1;justify-content:center">Mark all read</button><button class="btn ghost" id="inboxclr" style="flex:1;justify-content:center">Clear all</button></div>');
let items = [];
const draw = () => {
const l = el.querySelector('#inboxl'); if (!l) return; el.querySelector('#inboxbar').hidden = !items.length;
+1
View File
@@ -97,6 +97,7 @@
<script defer src="/signal-view.js"></script>
<script defer src="/chat-view.js"></script>
<script defer src="/chat-extras.js"></script>
<script defer src="/image-viewer.js"></script>
<script defer src="/notes-view.js"></script>
<script defer src="/chores-view.js"></script>
<script defer src="/family-view.js"></script>
+2 -2
View File
@@ -47,7 +47,7 @@ function lvPlay(b, at) {
}
async function viewFreeBooks(host) { // host: { el, hash } when shown inside the Store tab
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty">Loading…</p></div>';
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/audio/free'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty loading" role="status">Loading…</p></div>';
try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><p class="err">${esc(e.message)}</p></div>`; return; }
if (!here()) return;
const S = { q: '', genre: '', page: 1, items: [], more: false, busy: false, seq: 0 }; let genreList = []; lvApi('/genres').then(r => { genreList = r.genres || []; gchips(); }).catch(() => {});
@@ -78,7 +78,7 @@ async function viewFreeBooks(host) { // host: { el, hash } when shown inside the
async function lvSheet(id, after, known = new Map()) {
const sum = known.get(id) || lvShelf.find(x => x.id === id) || { id, title: 'Audiobook', author: '' };
const el = openSheet(sum.title, sum.author || '', '<p class="empty">Loading…</p>');
const el = openSheet(sum.title, sum.author || '', '<p class="empty loading" role="status">Loading…</p>');
let b; try { b = await lvApi('/book/' + id); } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
if (!document.body.contains(el)) return;
const p = lvLeft(id), total = lvTotal(b), onShelf = () => lvShelf.some(x => x.id === id);
+26 -11
View File
@@ -21,7 +21,7 @@ function lyrParseLrc(text) {
}
const lyrSpokenKey = t => !!t && /^(pod|ab):/.test(String(t.key)); // a podcast episode or an audiobook: words from a transcript instead of lyrics
const lyrLabel = () => lyrSpokenKey(typeof cur === 'function' ? cur() : null) ? 'Transcript' : 'Lyrics';
const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
const lyr = { fs: false, 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 {} };
// 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
@@ -77,41 +77,49 @@ async function lyrLoad(t, force) {
return lyrForRadio(t, res);
}
function lyrOpen() {
const t = cur(); if (!t || lyr.open) return;
lyr.open = true; document.body.classList.add('lyropen');
// fs: the full-screen Now Playing view (art on the left, the words on the right, the player along the bottom)
function lyrOpen(fs) {
const t = cur(); if (!t) return;
if (lyr.open) { if (!!fs !== lyr.fs) { lyr.fs = !!fs; lyrShell(); lyrReload(); } return; }
lyr.fs = fs === true; lyr.open = true; document.body.classList.add('lyropen');
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
}
function lyrClose() {
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.idx = -2; document.body.classList.remove('lyropen');
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.fs = false; if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); lyr.idx = -2; document.body.classList.remove('lyropen');
}
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
const lyrCls = () => (lyrPref().karaoke && !lyrSpokenKey(cur()) ? 'kar' : '') + (lyr.fs ? ' fs' : '');
function lyrShell() {
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
lyr.el.className = pref.karaoke && !lyrSpokenKey(t) ? 'kar' : '';
lyr.el.className = lyrCls();
lyr.el.style.setProperty('--lf', pref.size + 'px');
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
<div class="lyrtop"><img class="lyrart" alt="" src="${px.art(t.thumb, 120)}"><div class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small><small id="lyrsrc"></small></div>
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
<div class="fsside"><img class="fsimg" alt="" src="${px.art(t.thumb, 800)}"><b>${esc(t.title)}</b><small>${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
<div class="fsbar"><span id="fsc">0:00</span><input type="range" id="fsk" min="0" step="1" value="0" aria-label="Seek"><span id="fsd">0:00</span></div>
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><button class="btn ghost" id="lyrsh">Share a line</button><span class="grow"></span>
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
<button class="ibtn fsonly" id="fss" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button><button class="ibtn fsonly" id="fsr" aria-label="Repeat">${icon('repeat')}</button></div>`;
const $l = q => lyr.el.querySelector(q);
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; $l('#lyrsh').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
const fk = $l('#fsk'); fk.oninput = () => { fk.dataset.drag = 1; $l('#fsc').textContent = fmtDur(fk.value * 1000); }; fk.onchange = () => { pl.a.currentTime = +fk.value; delete fk.dataset.drag; };
$l('#fss').onclick = () => setShuffle(!pl.shuffle); $l('#fsr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
if (lyr.fs && !document.fullscreenElement && lyr.el.requestFullscreen) lyr.el.requestFullscreen().catch(() => {});
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrCls(); $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
$l('#lyrs').onclick = lyrSearchAgain; $l('#lyrsh').onclick = lyrShare;
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(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
syncUI();
modeUI(); syncUI();
}
async function lyrReload(force) {
const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2;
@@ -129,14 +137,21 @@ function lyrDraw() {
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.why && !d.error ? `<p class="empty"><small>${esc(d.why)}</small></p>` : ''}${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`;
const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return;
}
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return;
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; lyrQueue(body); return;
}
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; lyrQueue(body); return; }
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' || 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;
}
/** With no words to show, the space shows what is coming up instead (tap a song to jump to it). */
function lyrQueue(body) {
if (lyrIsRadio(cur()) || pl.i < 0 || pl.q.length < 2) return;
const rows = pl.q.map((t, i) => ({ t, i })).slice(pl.i, pl.i + 40);
body.insertAdjacentHTML('beforeend', `<div class="lyrq"><h4>Up next</h4>${rows.map(({ t, i }) => `<button class="lyrqrow${i === pl.i ? ' now' : ''}" data-j="${i}"><span class="num">${i === pl.i ? '\u25B6' : i - pl.i}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>`).join('')}</div>`);
body.querySelectorAll('.lyrqrow').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); });
}
function lyrSearchAgain() {
const t = cur(); if (!t) return; const q = lyrQuery(t);
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">
+2 -2
View File
@@ -165,7 +165,7 @@ async function viewMail(args = []) {
st = saved; mail.st = saved;
jfetch('/api/mail/status?light=1').then(fresh => { if (!fresh.connected || fresh.accounts.map(a => a.id).join() !== saved.accounts.map(a => a.id).join()) { mail.st = null; mcClear(); if (location.hash.startsWith('#/mail')) route(); } else { mail.st = { ...fresh, accounts: fresh.accounts.map(a => ({ ...a, unread: (mail.st.accounts.find(x => x.id === a.id) || {}).unread ?? a.unread })) }; mcPut('st', mail.st); } }).catch(() => {});
} else {
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty loading" role="status">Loading…</p></div>';
try { st = await jfetch('/api/mail/status?light=1'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (st.connected) mcPut('st', { connected: true, accounts: st.accounts, outlook: st.outlook, providers: st.providers });
}
@@ -241,7 +241,7 @@ async function mailLoadList(reset) {
mail.items = []; mail.next = 0; mail.more = false;
const saved = first && mcGet('l:' + folder);
if (saved) { mail.items = saved.items; mail.more = saved.more; mail.next = saved.next; mailDrawList(); } // last time's list, right away
else { const rows = $('#mrows'); if (rows) rows.innerHTML = '<p class="empty">Loading…</p>'; }
else { const rows = $('#mrows'); if (rows) rows.innerHTML = '<p class="empty loading" role="status">Loading…</p>'; }
}
const showing = reset && mail.items.length; // a saved list is on screen: the fresh one replaces it instead of being added to it
try {
+11 -4
View File
@@ -50,9 +50,9 @@ async function mmRandomTracks(sec, n, pls = []) {
}
// ---- a page of songs (a mix, a genre, favorites...): Play, Shuffle, Save as playlist, and the list
function mmPage(v, { title, sub, thumb, tracks, back, about, extra = '', onChange }) {
function mmPage(v, { title, sub, thumb, cover, tracks, back, about, extra = '', onChange }) {
const hrefBack = back ? `<div class="bar"><a class="btn ghost" href="${back[0]}">${icon('back')}${esc(back[1])}</a></div>` : '';
v.innerHTML = `<div class="wrap">${hrefBack}<div class="mhead">${thumb ? `<img class="art" alt="" src="${px.art(thumb, 400)}">` : `<div class="art mixart" style="background:linear-gradient(135deg,hsl(${mmHue(title)} 55% 42%),hsl(${mmHue(title) + 40} 55% 26%))"></div>`}<div><h1>${esc(title)}</h1><p class="sub">${esc(sub || tracks.length + ' songs')}</p></div></div>
v.innerHTML = `<div class="wrap">${hrefBack}<div class="mhead">${cover ? cover : thumb ? `<img class="art" alt="" src="${px.art(thumb, 400)}">` : `<div class="art mixart" style="background:linear-gradient(135deg,hsl(${mmHue(title)} 55% 42%),hsl(${mmHue(title) + 40} 55% 26%))"></div>`}<div><h1>${esc(title)}</h1><p class="sub">${esc(sub || tracks.length + ' songs')}</p></div></div>
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}
<div class="bar"><button class="btn" id="mpa">${icon('play')}Play</button><button class="btn ghost" id="mps">${icon('shuffle')}Shuffle</button><button class="btn ghost" id="mpl">${icon('plus')}Save as playlist</button>${extra}</div>
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span><small>${fmtDur(t.dur)}</small></button>
@@ -229,13 +229,20 @@ async function musicStats({ v, st }, range = 'month') {
}
// ================================================================ Family playlists
const famCard = p => `<a class="acard" href="#/music/family/${p.id}"><span class="mixart" style="background:linear-gradient(135deg,hsl(${mmHue(p.name)} 55% 42%),hsl(${mmHue(p.name) + 40} 55% 26%))"><b>${esc(p.name)}</b></span><small>${p.count} songs · ${esc(p.ownerName)}</small></a>`;
/** Plex-style cover for a playlist: the art of its songs in a 2x2 grid (one picture if they share one), or a colored tile when no song has art. */
const mmMosaic = (thumbs, name, cls = '') => {
const u = [...new Set((thumbs || []).filter(Boolean))].slice(0, 4);
if (!u.length) return `<span class="mixart ${cls}" style="background:linear-gradient(135deg,hsl(${mmHue(name)} 55% 42%),hsl(${mmHue(name) + 40} 55% 26%))"></span>`;
const img = t => `<img loading="lazy" alt="" src="${px.art(t, 300)}">`;
return `<span class="mosaic ${cls}${u.length < 4 ? ' one' : ''}">${(u.length < 4 ? u.slice(0, 1) : u).map(img).join('')}</span>`;
};
const famCard = p => `<a class="acard" href="#/music/family/${p.id}">${mmMosaic(p.tracks.slice(-40).reverse().map(t => t.thumb), p.name)}<b>${esc(p.name)}</b><small>${p.count} songs · ${esc(p.ownerName)}</small></a>`;
async function musicFamilyPage({ v }, id) {
mmLoading(v, 'Family playlist');
let p; try { p = await mmApi('/family/' + id); } catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/playlists">${icon('back')}Playlists</a></div><p class="err">${esc(e.message)}</p></div>`; return; }
const tracks = await mmTracksByKeys(p.tracks.map(t => t.key)); if (!location.hash.startsWith('#/music/family')) return;
const mineOrAdmin = ME && (ME.role === 'admin' || ME.id === p.owner);
mmPage(v, { title: p.name, sub: `${tracks.length} songs · a Family playlist by ${p.ownerName}. Everyone can add and remove songs.`, tracks, back: ['#/music/playlists', 'Playlists'], extra: `${mineOrAdmin ? '<button class="btn ghost" id="mfr">Rename</button><button class="btn danger" id="mfd">Delete</button>' : ''}`, onChange: () => {} });
mmPage(v, { title: p.name, cover: mmMosaic(tracks.map(t => t.thumb), p.name, 'art'), sub: `${tracks.length} songs · a Family playlist by ${p.ownerName}. Everyone can add and remove songs.`, tracks, back: ['#/music/playlists', 'Playlists'], extra: `${mineOrAdmin ? '<button class="btn ghost" id="mfr">Rename</button><button class="btn danger" id="mfd">Delete</button>' : ''}`, onChange: () => {} });
v.querySelectorAll('.row.it').forEach(r => { const t = tracks[+r.dataset.i], b = document.createElement('button'); b.className = 'ibtn'; b.setAttribute('aria-label', 'Remove from this playlist'); b.innerHTML = icon('trash'); b.onclick = async () => { try { await mmApi(`/family/${id}/tracks/${t.key}`, { method: 'DELETE' }); toast('Removed'); route(); } catch (e) { toast(e.message); } }; r.appendChild(b); });
if ($('#mfr')) $('#mfr').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await mmApi('/family/' + id, mmOpt('POST', { name: n })); route(); } catch (e) { toast(e.message); } }, p.name);
if ($('#mfd')) $('#mfd').onclick = () => confirmSheet('Delete this Family playlist?', 'It disappears for everyone.', 'Delete', async () => { try { await mmApi('/family/' + id, { method: 'DELETE' }); toast('Deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
+18 -5
View File
@@ -38,7 +38,7 @@ const mapTrack = (t, mc) => {
async function viewMusic(args) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
const st = await jfetch('/api/plex/status');
if (!location.hash.startsWith('#/music')) return;
@@ -115,13 +115,17 @@ async function musicRoute([kind = 'featured', key, key2], st) {
px.serverKey = st.server;
const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0];
const tabs = ['featured', 'mixes', 'albums', 'artists', 'playlists', 'favorites', 'stats', 'recent'], TAB_NAME = { recent: 'New', stats: 'Stats' };
const seg = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${TAB_NAME[t] || t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
const tname = t => TAB_NAME[t] || t[0].toUpperCase() + t.slice(1), MAIN = ['featured', 'albums', 'artists', 'playlists'], MORE = tabs.filter(t => !MAIN.includes(t));
// wide screens: every tab in one row. phones: the four main tabs fill one row and the rest are behind "More"
const seg = `<div class="seg segwide">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${tname(t)}</a>`).join('')}</div>
<div class="seg segphone">${MAIN.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${tname(t)}</a>`).join('')}<a href="#" id="segmore" class="${MORE.includes(kind) ? 'on' : ''}">${MORE.includes(kind) ? tname(kind) : 'More'} ▾</a></div>`;
const card = (href, thumb, title, sub, round) => `<a class="acard ${round ? 'round' : ''}" href="${href}" data-q="${esc((title + ' ' + sub).toLowerCase())}">
<img class="art" loading="lazy" alt="" src="${px.art(thumb)}"><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
${thumb ? `<img class="art" loading="lazy" alt="" src="${px.art(thumb)}">` : '<span class="mixart"></span>'}<b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
const listing = (items, html) => {
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}<span id="phu"></span></p>${seg}${items === 'raw' ? '' : '<input type="search" id="mq" placeholder="Filter" autocomplete="off">'}
<div class="${items === 'raw' ? '' : 'agrid'}" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
plexHomeBadge(st);
const sm = $('#segmore'); if (sm) sm.onclick = e => { e.preventDefault(); actionSheet('Music', '', MORE.map(t => ({ label: tname(t) + (t === kind ? ' ✓' : ''), icon: 'music', run: () => { location.hash = '#/music/' + t; } }))); };
const pl2 = $('#plib');
if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music',
run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); };
@@ -173,7 +177,8 @@ async function musicRoute([kind = 'featured', key, key2], st) {
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
${info.summary ? `<details class="mmabout"><summary>About</summary><p>${esc(info.summary).replace(/\n/g, '<br>')}</p></details>` : ''}
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
<div class="bar mprime"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button></div>
<div class="bar msecond">${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pmv">Move to NE Hub</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
@@ -182,10 +187,18 @@ async function musicRoute([kind = 'featured', key, key2], st) {
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); });
if (mine) {
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
$('#pmv').onclick = () => tracks.length ? textSheet('Move to NE Hub', 'Name of the Family playlist', 'Move', async n => {
try {
const meta = tracks.map(mmMeta).filter(t => mmPlex(t.key)); if (!meta.length) return toast('No songs here can be moved');
const r = await mmApi('/family', mmOpt('POST', { name: n, tracks: meta.slice(0, 500) }));
for (let i = 500; i < meta.length; i += 500) await mmApi(`/family/${r.id}/add`, mmOpt('POST', { tracks: meta.slice(i, i + 500) }));
toast('Moved ' + meta.length + ' songs to NE Hub');
confirmSheet('Remove it from Plex too?', '“' + title + '” is now a Family playlist. Cancel keeps the Plex one.', 'Remove from Plex', async () => { try { await plx.del(key); } catch (e) { toast(e.message); } location.hash = '#/music/family/' + r.id; });
} catch (e) { toast(e.message); }
}, title) : toast('This playlist has no songs');
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
}
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
markPlaying();
+2 -2
View File
@@ -17,7 +17,7 @@ async function ntLoad() { const r = await ntApi('/'); nt.notes = r.notes; nt.peo
// ================================================================ the list of notes
async function ntHome() {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Notes</h1><p class="empty">Loading…</p></div>';
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Notes</h1><p class="empty loading" role="status">Loading…</p></div>';
try { await ntLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Notes</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/notes')) return;
const filters = [['all', 'All'], ['list', 'Lists'], ['note', 'Notes'], ['mine', 'Mine'], ['shared', 'Shared'], ['archived', 'Archived']];
@@ -45,7 +45,7 @@ function ntNewSheet() {
// ================================================================ one note or list
async function ntEditor(id) {
const v = $('#view'); v.innerHTML = '<div class="wrap"><p class="empty">Loading…</p></div>';
const v = $('#view'); v.innerHTML = '<div class="wrap"><p class="empty loading" role="status">Loading…</p></div>';
let n; try { await ntLoad(); n = nt.notes.find(x => x.id === id); if (!n) throw new Error('That note is not available (it may have been deleted, or is not shared with you).'); } catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/notes">${icon('back')}Notes</a></div><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/notes/')) return;
let saveT, busy = false, dirty = false;
+1 -1
View File
@@ -97,7 +97,7 @@ function ofDraw(skeleton) {
const row = d => { const path = ofPath(d), on = pins.has(ofId(loc.repo, path)), i = shown.indexOf(d);
return `<div class="row it orow"><button class="rmain" data-o="${i}">${ofIcon(d.name)}<span class="grow"><b>${esc(d.name)}</b><small>${esc((d.parent_dir === '/' ? '' : d.parent_dir.replace(/^\//, '') + ' · ') + fmtDate(d.mtime) + (d.size ? ' · ' + fmtSize(d.size) : ''))}</small></span></button>
<button class="ibtn" data-pin="${i}" aria-label="${on ? 'Unpin' : 'Pin'} ${esc(d.name)}" title="${on ? 'Unpin' : 'Pin to the top'}" style="${on ? 'color:var(--accent)' : ''}">${icon(on ? 'check' : 'plus')}</button><button class="ibtn" data-m="${i}" aria-label="Actions for ${esc(d.name)}">${icon('more')}</button></div>`; };
box.innerHTML = skeleton ? '<p class="empty">Loading your documents…</p>' : (recents.length ? `<h2>Recently opened</h2><div class="orecent">${recents.map((r, i) => `<button class="octile rc" data-r="${i}" style="--c:${OFFICE_KINDS[ofKind(r.name)].color}">${ofIcon(r.name)}<span><b>${esc(r.name)}</b><small>${esc(new Date(r.t).toLocaleDateString([], { month: 'short', day: 'numeric' }))}</small></span></button>`).join('')}</div>` : '')
box.innerHTML = skeleton ? '<p class="empty loading" role="status">Loading your documents…</p>' : (recents.length ? `<h2>Recently opened</h2><div class="orecent">${recents.map((r, i) => `<button class="octile rc" data-r="${i}" style="--c:${OFFICE_KINDS[ofKind(r.name)].color}">${ofIcon(r.name)}<span><b>${esc(r.name)}</b><small>${esc(new Date(r.t).toLocaleDateString([], { month: 'short', day: 'numeric' }))}</small></span></button>`).join('')}</div>` : '')
+ (pinned.length && !q ? `<h2>Pinned</h2><div class="list">${pinned.map(row).join('')}</div>` : '')
+ (list.length ? `<h2>${q ? 'Results' : ofState.kind === 'all' ? 'All documents' : OFFICE_KINDS[ofState.kind].label} <small style="color:var(--muted);font-weight:400">${list.length}</small></h2><div class="list">${shown.map(row).join('')}</div>${list.length > shown.length ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="ofmore">Show ${Math.min(40, list.length - shown.length)} more (${list.length - shown.length} not shown)</button></div>` : ''}`
: `<p class="empty">${ofState.docs.length ? 'Nothing matches.' : 'No documents here yet. Create one above, or drop files on this page.'}</p>`);
+90 -35
View File
@@ -22,9 +22,12 @@ const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSear
// so changing songs is instant and a slow or lost connection does not stop the music.
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
const tc = { m: new Map(), ctl: null, busy: false };
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); // (long audio, such as audiobooks, is streamed, not saved whole)
const tcCount = () => Math.min(5, Math.max(3, +store.get('song_cache', 5) || 5)); // songs kept ready, counting the one playing (Settings: 3 to 5)
const TC_BYTES = 120e6; // however many songs, never hold more than this in memory (a lossless album could otherwise fill a phone)
const tcWant = () => { const w = pl.q.slice(Math.max(pl.i, 0), Math.max(pl.i, 0) + tcCount()).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); return w; }; // (the one playing first, so a dropped connection cannot stop it, then the ones coming up; long audio, such as audiobooks, is streamed, not saved whole)
const tcBytes = () => { let n = 0; for (const v of tc.m.values()) n += v.size || 0; return n; };
function tcTrim() {
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), Math.max(pl.i, 0) + tcCount()).map(t => t.src));
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
}
async function tcFetch(src, signal) {
@@ -32,7 +35,7 @@ async function tcFetch(src, signal) {
try {
const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
const b = await r.blob(); // throws if the connection drops part way
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b) });
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b), size: b.size });
return;
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
}
@@ -42,7 +45,8 @@ async function tcPump() {
try {
for (;;) {
tcTrim();
const src = tcWant().find(x => !tc.m.has(x)); if (!src) break;
const want = tcWant(), src = want.find(x => !tc.m.has(x)); if (!src) break;
if (tcBytes() > TC_BYTES && want.indexOf(src) > 1) break; // (the song playing and the next one are always kept; the ones after wait while the cache is full)
tc.ctl = new AbortController(); tc.cur = src;
await tcFetch(src, tc.ctl.signal);
if (!tc.ctl.signal.aborted && !tc.m.has(src)) break; // gave up on this one; try again on the next trigger
@@ -51,25 +55,42 @@ async function tcPump() {
}
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && cur() && !pl.a.paused && (pl.a.readyState < 3 || pl.a.error)) { pl.retries = 0; recover(true); } }); // (back from sleep or another app)
let loadTok = 0, loadTimer = null, stallTimer = null;
// Watchdog: a song that sits on "Buffering…" without moving is not left there. After 10 s it is reloaded, after 20 s it is switched to the copy Plex converts to MP3,
// and after 30 s it is skipped. (This does not rely on the browser sending a "stalled" or "error" event, which a hung request may never do.)
const wd = { tries: 0, at: 0, pos: -1, key: '' };
if (typeof setInterval === 'function') setInterval(() => {
const t = cur(), a = pl.a;
if (!t || !pl.buf || /^radio:/.test(String(t.key)) || !navigator.onLine) { wd.at = Date.now(); wd.pos = -1; return; }
if (wd.key !== t.key) { wd.key = t.key; wd.tries = 0; wd.at = Date.now(); }
if (a.currentTime !== wd.pos) { wd.pos = a.currentTime; wd.at = Date.now(); return; }
if (Date.now() - wd.at < 10000 || loadTimer && a.paused && Date.now() - wd.at < 12000) return;
wd.at = Date.now(); wd.tries++;
if (wd.tries === 1) { pl.forceRecover = true; pl.recovering = true; recover(true); }
else if (wd.tries === 2) { pl.alt.add(t.key); loadCur(0); }
else { wd.tries = 0; toast('"' + t.title + '" would not load. Skipping.'); advance(true); }
}, 2000);
pl.retries = 0; pl.buf = false;
// Show the new track at once, but wait a moment before fetching it so quickly tapping Next several times only loads the last one.
function loadCur(delay = 220) {
const t = cur(); if (!t) return;
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false; pl.recovering = false; wd.tries = 0; wd.at = Date.now();
const tok = ++loadTok; pl.buf = true;
if (delay) pl.a.pause();
try { // (a bug in any screen's refresh must never stop the music from starting)
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
if (lyr.open) { lyrShell(); lyrReload(); }
if (typeof vizTrackChanged === 'function') vizTrackChanged();
if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint();
} catch (e) { console.error('player refresh', e); }
const go = () => {
if (tok !== loadTok) return;
const hit = tc.m.get(t.src);
pl.a.src = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
if (typeof actLog === 'function') actLog(/podcast|audiobook|\/ab\/|radio/.test(String(t.src || '')) ? 'listen' : 'play', (t.artist ? t.artist + ' - ' : '') + (t.title || ''));
pl.a.play().catch(e => { if (e && e.name === 'NotAllowedError') { pl.buf = false; syncUI(); toast('Tap play to start'); } });
pl.a.play().catch(e => { if (tok !== loadTok) return; if (e && e.name === 'NotAllowedError') { pl.buf = false; syncUI(); toast('Tap play to start'); } else if (!(e && e.name === 'AbortError')) { pl.forceRecover = true; pl.recovering = true; recover(true); } });
tcKick();
const n = pl.q[pl.i + 1]; if (n) new Image().src = px.art(n.thumb, 600);
};
@@ -77,14 +98,15 @@ function loadCur(delay = 220) {
}
function setMeta(t) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [96, 128, 256, 384, 512].map(n => ({ src: px.art(t.thumb, n), sizes: n + 'x' + n })) });
}
// If the music stalls, switch to the fully downloaded copy (if there is one) or reload the stream from where it stopped.
function recover(now) {
clearTimeout(stallTimer); const t = cur(), a = pl.a;
if (!t || a.paused) return;
if (!t || (a.paused && !pl.recovering)) return;
stallTimer = setTimeout(() => {
if (a.paused || (a.readyState >= 3 && !a.error)) return;
const forced = pl.forceRecover; pl.forceRecover = false;
if ((a.paused && !pl.recovering) || (!forced && a.readyState >= 3 && !a.error)) return; // (a reload makes the element report "paused" by itself, which must not stop us trying again)
const hit = tc.m.get(t.src), pos = a.currentTime;
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
if (bad && !hit) {
@@ -93,12 +115,14 @@ function recover(now) {
}
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
if (!hit && !navigator.onLine) return; // 'online' will call us again
if (!hit && !bad && pl.retries >= 5) { pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); return; }
if (!hit && !bad && pl.retries >= 5) { // keep trying every 15 s (the 'online' signal is not always sent, e.g. when only the server was unreachable)
if (!pl.gaveUp) { pl.gaveUp = true; pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); }
stallTimer = setTimeout(() => { pl.retries = 0; recover(true); }, 15000); return; }
if (!hit || a.src !== hit.url) pl.retries++;
a.src = want;
pl.recovering = true; a.src = want;
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
recover();
}, now ? 300 : 4000);
}, now ? 300 : a.error ? 1200 : 4000); // (a file that plainly will not load is not waited on for long)
}
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); }
const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); npQueueCount(); toast('Playing next'); };
@@ -126,7 +150,28 @@ function restorePlayer() {
pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
renderMini(); syncUI();
}
// On a wide screen the queue docks down the right side of the page and stays open while you browse; the Queue button turns it on and off.
const qWide = () => typeof matchMedia === 'function' && matchMedia('(min-width: 1100px)').matches;
function qDock() {
if (!qWide() && !document.body?.classList.contains('qdock')) return; // (narrow screens use the sheet)
let el = document.getElementById('qdock'), on = store.get('q_dock', false) && qWide() && pl.q.length && !$('#player').hidden;
if (!on) { if (el) el.remove(); document.body.classList.remove('qdock'); return; }
if (!el) { el = document.createElement('aside'); el.id = 'qdock'; el.setAttribute('aria-label', 'Up next'); $('#main').appendChild(el); }
document.body.classList.add('qdock'); el.style.bottom = ($('#player').offsetHeight || 0) + 'px';
const keep = el.querySelector('.qlist')?.scrollTop || 0;
el.innerHTML = `<div class="qhead"><b>Up next</b><small>${pl.q.length} tracks${pl.radio ? ' · radio' : ''}</small><button class="ibtn" id="qdx" aria-label="Hide the queue">${icon('x')}</button></div>
<div class="qlist">${pl.q.map((t, i) => `<div class="qrow ${i === pl.i ? 'now' : ''}" data-i="${i}"><button class="qmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove" title="Remove">${icon('x')}</button>`}</div>`).join('')}</div>
<div class="qfoot"><button class="btn ghost" id="qdc">Clear up next</button><button class="btn ghost" id="qds">Save as playlist</button></div>`;
const list = el.querySelector('.qlist'); list.scrollTop = keep || Math.max(0, (list.querySelector('.now')?.offsetTop || 0) - 60);
el.querySelector('#qdx').onclick = () => { store.set('q_dock', false); qDock(); };
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); });
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); tcKick(); updateNow(); });
el.querySelector('#qdc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); };
el.querySelector('#qds').onclick = () => addToPlaylist(pl.q.slice(), 'Queue ' + new Date().toLocaleDateString([], { month: 'short', day: 'numeric' }));
}
if (typeof matchMedia === 'function') matchMedia('(min-width: 1100px)').addEventListener('change', () => qDock());
function openQueue() {
if (qWide()) { store.set('q_dock', !store.get('q_dock', false)); return qDock(); }
const upcoming = pl.q.map((t, i) => ({ t, i }));
const el = openSheet('Up next', pl.q.length + ' tracks' + (pl.radio ? ' · library radio is on' : ''), `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
@@ -150,7 +195,7 @@ function advance(auto) {
}
const next = () => advance(false);
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else if (pl.i > 0) { pl.i--; loadCur(); } };
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); return pl.a.paused ? pl.a.play() : pl.a.pause(); };
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); pl.recovering = false; return pl.a.paused ? pl.a.play() : pl.a.pause(); };
pl.a.preload = 'auto';
// Tell Plex what is playing, so it shows under Now Playing / Activity in the Plex dashboard (as "NE Hub", under the signed-in Plex account).
let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
@@ -162,7 +207,10 @@ function tlReport(state) {
tlSend(state, tlKey, tlPos, tlDur);
}
function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
pl.a.addEventListener('ended', () => advance(true));
pl.a.addEventListener('ended', () => { // a dropped connection can look like the end of the song: if it stopped well short of the length, carry on from there instead of skipping
const a = pl.a, t = cur(), d = t && t.dur ? t.dur / 1000 : 0;
if (t && d > 20 && !/^radio:/.test(String(t.key)) && a.currentTime < d - 8 && pl.endRetry !== t.key + '|' + Math.floor(a.currentTime / 5)) { pl.endRetry = t.key + '|' + Math.floor(a.currentTime / 5); pl.forceRecover = true; pl.buf = true; syncUI(); recover(true); return; }
advance(true); });
let lastSave = 0, lastPos = 0;
pl.a.addEventListener('timeupdate', () => {
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
@@ -174,14 +222,14 @@ pl.a.addEventListener('timeupdate', () => {
});
addEventListener('pagehide', saveState);
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
pl.a.addEventListener('playing', () => { pl.recovering = false; tlReport('playing'); pl.buf = false; pl.retries = 0; pl.gaveUp = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
pl.a.addEventListener('pause', () => { if (pl.recovering) return; if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
pl.a.addEventListener('canplaythrough', () => tcKick());
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; syncUI(); recover(); });
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; pl.recovering = true; syncUI(); recover(); });
if ('mediaSession' in navigator) {
const ms = navigator.mediaSession;
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => pl.a.pause());
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => { pl.recovering = false; pl.a.pause(); });
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
try { ms.setActionHandler('seekbackward', d => { pl.a.currentTime = Math.max(0, pl.a.currentTime - (d.seekOffset || 10)); }); ms.setActionHandler('seekforward', d => { pl.a.currentTime += d.seekOffset || 10; }); } catch {}
@@ -191,16 +239,16 @@ function markPlaying() {
}
function renderMini() {
if (typeof videoBarOn === 'function' && videoBarOn()) return; // the bottom bar is showing the video's controls
const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
el.hidden = false;
const t = cur(), el = $('#player'); if (!t) { el.hidden = true; return qDock(); }
el.hidden = false; setTimeout(qDock);
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="mv" aria-label="Visuals" title="Visuals">${icon('visuals')}</button><button class="ibtn wide-only" id="mq" aria-label="Queue" title="Queue">${icon('queue')}</button><button class="ibtn wide-only" id="mz" aria-label="Sleep timer" title="Sleep timer">${icon('moon')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen;
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen; $('#mv').onclick = () => vizOpen(); $('#mq').onclick = openQueue; $('#mz').onclick = () => sleepSheet();
// tap or drag along the thin bar to seek
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
@@ -221,8 +269,8 @@ function stopPlayer() {
}
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
function modeUI() {
document.querySelectorAll('#ms, #nps, #cns').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
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); });
document.querySelectorAll('#ms, #nps, #cns, #fss').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
document.querySelectorAll('#mr, #npr, #cnr, #fsr').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, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
@@ -246,15 +294,18 @@ function syncUI() {
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000);
const fk = $('#fsk'); if (fk && !fk.dataset.drag) { fk.max = d || 0; fk.value = liveT ? 0 : a.currentTime; } if (fk) fk.hidden = liveT;
const fc = $('#fsc'); if (fc) fc.textContent = liveT ? '\u25CF LIVE' : fmtDur(a.currentTime * 1000);
const fd = $('#fsd'); if (fd) fd.textContent = liveT ? '' : fmtDur(d * 1000);
const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; }
}
// A Now Playing card with the controls: on the Home dashboard, and at the top of every Music screen (the albums, artists and playlists stay right below it).
// A Now Playing card with the controls on the Home dashboard (the Music screens don't repeat it: the bottom player has the same controls).
function renderNpCard() {
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music');
const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null;
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home');
const host = onHome ? $('#npslot') : null;
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
if (!host.contains(el)) { if (onHome) host.appendChild(el); else host.prepend(el); }
if (!host.contains(el)) host.appendChild(el);
el.innerHTML = `<button class="npc-art" id="cnart" aria-label="Open the full player"><img alt="" src="${px.art(t.thumb, 300)}"></button>
<div class="npc-text"><small class="npc-lbl">NOW PLAYING</small><b id="cnti">${esc(t.title)}</b><small id="cnsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
<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>
@@ -271,24 +322,28 @@ function renderNpCard() {
}
// The Now Playing sheet stays open while tracks change, so refresh what it shows.
function updateNow() {
qDock();
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next';
const q = $('#npq span'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1);
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
modeUI();
}
function openNow() {
const t = cur(); if (!t) return;
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0" id="npti">${esc(t.title)}</h3>
<p class="sub" id="npsub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
<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="ibtn" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</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 chip = (id, ic, label) => `<button class="npchip" id="${id}">${icon(ic)}<span>${label}</span></button>`;
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><div class="npmeta"><h3 id="npti">${esc(t.title)}</h3>
<p class="sub" id="npsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p></div>
<div class="npseek"><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>
<div class="ctl"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><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><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div>
<div class="npchips"><button class="npchip" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}<span>Favorite</span></button>${chip('npl', 'lyrics', lyrLabel())}${chip('npv', 'visuals', 'Visuals')}${chip('npq', 'queue', 'Queue · ' + (pl.q.length - pl.i - 1))}${castOk(pl.a) ? chip('npcast', 'ext', 'Cast') : ''}${chip('npz', 'moon', 'Sleep')}${chip('npfs', 'full', 'Full screen')}</div></div>`);
el.querySelector('.sheet').classList.add('npsheet');
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(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
el.querySelector('#npz').onclick = sleepSheet; el.querySelector('#npfs').onclick = () => { closeSheet(); lyrOpen(true); };
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint);
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
+1 -1
View File
@@ -34,7 +34,7 @@ const pdLeft = e => { const p = pdProg[pdId(e)]; return p && p.pos > 10 && p.dur
async function viewPodcast(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Podcast</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Podcast</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
if (args[0] === 'show' && args[1]) return await pdShow(args[1]);
return await pdHome();
+2 -2
View File
@@ -39,7 +39,7 @@ setInterval(rdoPoll, 10000); pl.a.addEventListener('playing', () => { if (rdoPla
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(host) { // host: { el, hash } when shown inside the Store (the station finder); on the Radio tab itself only what was added from the Store is listed
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '<div class="wrap"><h1>Radio</h1><p class="empty">Loading…</p></div>';
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/radio'); v.innerHTML = '<div class="wrap"><h1>Radio</h1><p class="empty loading" role="status">Loading…</p></div>';
let feat, mine, added;
try { ({ featured: feat, mine, added } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Radio</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!here()) return;
@@ -83,7 +83,7 @@ async function viewRadio(host) { // host: { el, hash } when shown inside the Sto
$('#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>`; } };
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty loading" role="status">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>`; } };
$('#rdojx').onclick = async () => { try { await rdoApi('/stations/' + encodeURIComponent(j.id), rdoOpt('DELETE')); toast('Removed from Radio'); viewRadio(host); } catch (e) { toast(e.message); } };
rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {});
}
+3 -3
View File
@@ -29,7 +29,7 @@ async function viewReader(args = []) {
if (args[0] === 'open' && args[1]) return rdOpen(args[1]);
if (args[0] === 'store') return allowed('store') ? location.replace('#/store/books') : rdStore();
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Books</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Books</h1><p class="empty loading" role="status">Loading…</p></div>';
try { await rdLibrary(); } catch (e) {
if (!location.hash.startsWith('#/read')) return;
v.innerHTML = `<div class="wrap"><h1>Books</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rdretry">Try again</button></div></div>`;
@@ -167,7 +167,7 @@ async function rdMobiOpen(u8) {
// ======================================================= Book store: free, DRM-free books from Project Gutenberg, Standard Ebooks, the Internet Archive and any OPDS catalogue
async function rdStore(host) { // host: { el, hash } when shown inside the Store tab
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/read/store'); v.innerHTML = '<div class="wrap"><h1>Book store</h1><p class="empty">Loading…</p></div>';
const v = host ? host.el : $('#view'), here = () => location.hash.startsWith(host ? host.hash : '#/read/store'); v.innerHTML = '<div class="wrap"><h1>Book store</h1><p class="empty loading" role="status">Loading…</p></div>';
let sources, mine;
let genreList = []; try { [sources, mine, genreList] = await Promise.all([rdApi('/store/sources'), rdApi('/list'), rdApi('/store/genres').catch(() => [])]); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
if (!here()) return;
@@ -350,7 +350,7 @@ class RdReader {
async deleteNote(n) { this.A.eraseNote?.(n); await this.pushNote({ ...n, del: true }); this.notes = this.notes.filter(x => !x.del); this.$('#rdbm').classList.toggle('on', !!this.isMarked()); this.refreshNotes?.(); }
// ---- panels
async showToc() {
const el = openSheet('Contents', '', '<p class="empty">Loading…</p>'); let items = [];
const el = openSheet('Contents', '', '<p class="empty loading" role="status">Loading…</p>'); let items = [];
try { items = await this.A.toc(); } catch {}
el.querySelector('.empty').outerHTML = items.length ? `<div class="list rdlist">${items.map((t, i) => `<button class="row" data-i="${i}" style="padding-left:${16 + t.depth * 18}px"><span class="grow"><b>${esc(t.label)}</b></span></button>`).join('')}</div>` : '<p class="empty">This book has no table of contents.</p>';
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(items[b.dataset.i].loc); });
+1 -1
View File
@@ -9,7 +9,7 @@ let rcQuery = '', rcPage = 1;
async function viewRecipes(args = []) {
const v = $('#view');
v.innerHTML = '<div class="wrap"><h1>Recipes</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Recipes</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
if (args[0] === 'pantry') return await viewPantry(args[1]);
+1 -1
View File
@@ -39,7 +39,7 @@ async function famKids(box) {
box.querySelectorAll('[data-k]').forEach(b => b.onclick = () => famChild(kids.find(u => u.id === b.dataset.k)));
}
async function famChild(u) {
const el = openSheet(u.name, 'Last 7 days', '<div id="fcb"><p class="empty">Loading…</p></div>'); let a, px;
const el = openSheet(u.name, 'Last 7 days', '<div id="fcb"><p class="empty loading" role="status">Loading…</p></div>'); let a, px;
try { [a, px] = await Promise.all([jfetch('/api/kid/activity?user=' + encodeURIComponent(u.id) + '&days=7'), jfetch('/api/kid/plex-lock?user=' + encodeURIComponent(u.id))]); } catch (e) { el.querySelector('#fcb').innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const h = m => m >= 60 ? Math.floor(m / 60) + ' h ' + (m % 60) + ' min' : m + ' min', total = a.days.reduce((n, d) => n + d.minutes, 0), by = {}; a.days.forEach(d => Object.entries(d.areas).forEach(([k, m]) => { by[k] = (by[k] || 0) + m; }));
const icn = { play: '🎵', watch: '🎬', listen: '🎧', read: '📖', search: '🔎', signin: '🔑', chore: '✅', add: '➕', blocked: '⛔' }, max = Math.max(1, ...a.days.map(d => d.minutes));
+3
View File
@@ -91,6 +91,7 @@ function viewSettings(args) {
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
${allowed('music') ? '<label class="row"><span class="grow"><b>Songs kept ready</b><small>The playing song and the ones coming up are saved on this device, so a weak signal does not stop the music</small></span><select id="songcache"><option value="3">3</option><option value="4">4</option><option value="5">5</option></select></label>' : ''}
${allowed('music') || allowed('radio') ? '<label class="row" style="cursor:pointer"><span class="grow"><b>Look up lyrics and song covers online</b><small>Sends the artist and title of what is playing to LRCLIB and iTunes. Turn off to keep that private.</small></span><input type="checkbox" id="lookups"></label>' : ''}
</div>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
@@ -146,6 +147,7 @@ function viewSettings(args) {
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server + (st.user ? ' · as ' + st.user.title : '') : 'Signed in · no server chosen') : 'Signed out';
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
const sc = $('#songcache'); if (sc) { sc.value = String(Math.min(5, Math.max(3, +store.get('song_cache', 5) || 5))); sc.onchange = () => { store.set('song_cache', +sc.value); if (typeof tcKick === 'function') tcKick(); toast('Saved'); }; }
const lk = $('#lookups'); if (lk) { lk.checked = store.get('lookups', true) !== false; lk.onchange = () => { store.set('lookups', lk.checked); toast(lk.checked ? 'Lookups on' : 'Lookups off'); }; }
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); $('#stbtn').onclick = () => screenTimeSheet(); }
if (isAdmin) jfetch('/api/health').then(h => {
@@ -156,6 +158,7 @@ function viewSettings(args) {
(h.plex.configured ? row('Plex', h.plex.ok ? 'Answering in ' + h.plex.ms + ' ms' + (h.plex.user ? ' · using ' + esc(h.plex.user) : '') : esc(h.plex.error || 'Not answering'), !h.plex.ok) : row('Plex', 'Not set up', false))
+ (h.services || []).map(x => row(esc(x.name), x.ok ? 'Answering in ' + x.ms + ' ms' : esc(x.error || 'Not answering'), !x.ok)).join('')
+ (h.mail.length ? h.mail.map(m => row('Mail: ' + esc(m.address), m.ok ? 'Connected (' + m.ms + ' ms)' : esc(m.error || 'Could not connect'), !m.ok)).join('') : row('Mail', 'No account connected', false))
+ (h.push ? row('Reminders and push', h.push.devices ? h.push.devices + ' device' + (h.push.devices === 1 ? '' : 's') + ' for ' + h.push.people + (h.push.people === 1 ? ' person' : ' people') + ' · ' + h.push.sent + ' sent since the server started' + (h.push.expired ? ' · ' + h.push.expired + ' expired device' + (h.push.expired === 1 ? '' : 's') + ' removed' : '') + (h.push.failed ? ' · ' + h.push.failed + ' failed (last: ' + esc(h.push.lastErr) + ')' : '') : 'No device has turned reminders on yet', h.push.failed > 0 && h.push.lastErrAt > h.push.lastOk) : '')
+ (h.disk ? row('Disk', gb(h.disk.free) + ' free of ' + gb(h.disk.total), h.disk.free / h.disk.total < .1 || h.disk.free < 2e9) : '')
+ row('Last backup download', h.backup ? days(h.backup.t) + ' day' + (days(h.backup.t) === 1 ? '' : 's') + ' ago (by ' + esc(h.backup.who || '?') + ')' : 'Never. Use Download a backup below.', !h.backup || days(h.backup.t) > 30)
+ (h.transcripts && h.transcripts.enabled ? row('Transcripts', `${h.transcripts.saved} saved · ${h.transcripts.working ? 'working on ' + h.transcripts.working : 'idle'}${h.transcripts.failed ? ' · ' + h.transcripts.failed + ' failed recently' : ''} · ${h.transcripts.auto ? 'automatic for the newest ' + h.transcripts.keep + ' of each show' : 'automatic transcribing is off'}`, false) : '')
+1 -1
View File
@@ -64,7 +64,7 @@ function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return
async function sgMount(root, dock) {
sg.inChat = false; // (a full Signal screen or the floating panel; Chat sets this itself when it shows a Signal conversation)
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : `<h1>Signal</h1>${typeof sgInChat === 'function' && sgInChat() ? `<p class="sub" style="margin:0 0 8px"><a href="#/chat" style="color:var(--accent)">‹ Back to Chat</a> · your Signal chats are also listed there</p>` : ''}`;
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
root.innerHTML = `<div class="${cls}">${title}<p class="empty loading" role="status">Loading…</p></div>`;
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
if (sg.root !== root) return; sg.st = st;
if (!st.reachable) { root.innerHTML = `<div class="${cls}">${title}<div class="card" style="max-width:none"><p style="margin-top:0">The Signal service is not answering. Check that its container (<b>signal</b>) is running, then reload.</p></div></div>`; return; }
+47 -3
View File
@@ -29,19 +29,27 @@ async function viewStore(args = []) {
}
// ---------------------------------------------------------------- My Store
let stSelecting = false; const stPicked = new Set();
async function stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stSeen = r.seen || {}; stAdmin = r.admin; return r; }
async function stMine(el) {
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
const draw = () => {
if (!$('#stbody')) return;
const mine = stItems.filter(i => i.kind === 'extension' && stInstalled.includes(i.id)), shown = stItems.filter(i => stFilter === 'all' || i.kind === stFilter);
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><p class="sub" style="margin:0;flex:1">Books, music, apps and extensions added just for this hub.</p>${stAdmin ? `<button class="btn" id="stadd">${icon('plus')}Add</button>` : ''}</div>
el.innerHTML = `<div class="bar" style="margin:0 0 8px;justify-content:flex-start"><p class="sub" style="margin:0;flex:1">Books, music, apps and extensions added just for this hub.</p>${stAdmin ? `<label class="btn ghost" style="cursor:pointer;margin:0 6px 0 0" title="Pick many EPUB, PDF, MOBI, AZW3 or DOCX files at once. Titles, authors and covers are read from the books.">${icon('plus')}Add many books<input type="file" id="stbulk" hidden multiple accept=".epub,.pdf,.mobi,.azw3,.azw,.docx"></label><button class="btn ghost" id="stsel" style="margin:0 6px 0 0">${stSelecting ? 'Cancel' : 'Select'}</button><button class="btn" id="stadd">${icon('plus')}Add</button>` : ''}</div>
${stSelecting ? `<div class="bar" style="justify-content:flex-start;position:sticky;top:0;z-index:3;background:var(--bg,var(--panel));padding:8px 0;flex-wrap:wrap"><b style="margin-right:8px">${stPicked.size} selected</b><button class="btn ghost" id="stall">Select all shown</button><button class="btn" id="stgive"${stPicked.size ? '' : ' disabled'}>Add to people…</button></div>` : ''}
${mine.length ? `<h2>My extensions</h2><div class="vgrid">${mine.map(i => stCard(i, true)).join('')}</div>` : ''}
<div class="seg" id="stkinds"><a href="#" data-k="all" class="${stFilter === 'all' ? 'on' : ''}">All</a>${ST_KINDS.map(k => `<a href="#" data-k="${k[0]}" class="${stFilter === k[0] ? 'on' : ''}">${k[1]}</a>`).join('')}</div>
${shown.length ? `<div class="vgrid">${shown.map(i => stCard(i)).join('')}</div>` : `<p class="empty">${stItems.length ? 'Nothing of that kind yet.' : stAdmin ? 'Nothing here yet. Add your first book, album, app or extension.' : 'Nothing has been added yet.'}</p>`}`;
el.querySelectorAll('#stkinds a').forEach(a => a.onclick = e => { e.preventDefault(); stFilter = a.dataset.k; draw(); });
el.querySelectorAll('[data-it]').forEach(a => a.onclick = e => { e.preventDefault(); const it = stItems.find(x => x.id === a.dataset.it); if (a.dataset.run) location.hash = '#/store/ext/' + it.id; else stDetail(it, draw); });
el.querySelectorAll('[data-it]').forEach(a => a.onclick = e => { e.preventDefault(); const it = stItems.find(x => x.id === a.dataset.it);
if (stSelecting) { if (it.kind !== 'book' || !it.files.length) return toast('Only books can be given to people'); stPicked.has(it.id) ? stPicked.delete(it.id) : stPicked.add(it.id); return draw(); } if (a.dataset.run) location.hash = '#/store/ext/' + it.id; else stDetail(it, draw); });
if ($('#stadd')) $('#stadd').onclick = () => stEdit(null, draw);
if ($('#stsel')) $('#stsel').onclick = () => { stSelecting = !stSelecting; stPicked.clear(); if (stSelecting) stFilter = 'book'; draw(); };
el.querySelectorAll('[data-it]').forEach(a => a.classList.toggle('picked', stPicked.has(a.dataset.it)));
if ($('#stgive')) $('#stgive').onclick = () => stGive([...stPicked], () => { stSelecting = false; stPicked.clear(); draw(); });
if ($('#stall')) $('#stall').onclick = () => { const bs = shown.filter(i => i.kind === 'book' && i.files.length); bs.every(i => stPicked.has(i.id)) ? stPicked.clear() : bs.forEach(i => stPicked.add(i.id)); draw(); };
if ($('#stbulk')) $('#stbulk').onchange = e => { const fs = [...e.target.files]; e.target.value = ''; if (fs.length) stBulk(fs, draw); };
};
draw();
}
@@ -128,7 +136,7 @@ function stPickFile() {
const el = openSheet('Choose a file from Files', '', '<div id="stpk"></div>'); const finish = v => { done = true; closeSheet(); resolve(v); };
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
const draw = async () => {
const box = el.querySelector('#stpk'); box.innerHTML = '<p class="empty">Loading…</p>';
const box = el.querySelector('#stpk'); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
try {
if (!repo) { const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted).sort((a, b) => a.name.localeCompare(b.name));
box.innerHTML = `<div class="list" style="max-height:50dvh;overflow:auto">${repos.map(r => `<button class="row" data-r="${esc(r.id)}">${icon('files')}<span class="grow"><b>${esc(r.name)}</b></span></button>`).join('')}</div><div class="bar"><button class="btn ghost" id="pcx" style="flex:1;justify-content:center">Cancel</button></div>`;
@@ -246,3 +254,39 @@ async function stHomeExt(el) {
return `<a class="tile" href="${esc(href)}" ${ext ? 'target="_blank" rel="noopener noreferrer"' : ''}><span class="ic" style="background:hsl(${stHue(i.title)} 55% 42%);font-weight:700;font-size:20px">${esc(i.icon || (i.title.match(/\p{L}|\p{N}/u) || ['?'])[0].toUpperCase())}</span><b>${esc(i.title)}</b><small>${i.kind === 'extension' ? 'Extension' : 'Site'}</small></a>`; };
el.innerHTML = `<h2>My apps</h2><div class="grid">${mine.map(tileOf).join('')}</div>`;
}
// Bulk add books: each file goes up on its own and the server reads the title, author and cover out of the book.
async function stBulk(files, redraw) {
let stop = false, done = 0; const added = [], dups = [], failed = [], noCover = [], noAuthor = [];
const el = openSheet('Adding books', '', '<p class="sub" id="sbn"></p><div style="height:6px;border-radius:3px;background:var(--line);overflow:hidden"><div id="sbp" style="height:100%;width:0;background:var(--accent)"></div></div><p class="sub" id="sbl" style="margin-top:10px;word-break:break-word"></p><div id="sbr"></div><div class="bar"><button class="btn ghost" id="sbx" style="flex:1;justify-content:center">Stop</button></div>');
const q = s => el.querySelector(s); q('#sbx').onclick = () => { if (done >= files.length || stop) closeSheet(); else { stop = true; q('#sbx').textContent = 'Stopping…'; } };
for (const f of files) {
if (stop) break;
q('#sbn').textContent = `Book ${done + 1} of ${files.length}`; q('#sbl').textContent = f.name;
try {
const r = await stApi('/import?name=' + encodeURIComponent(f.name), { method: 'POST', body: f, headers: { 'content-type': 'application/octet-stream' } });
if (r.duplicate) dups.push(f.name); else { added.push(r.item); if (r.noCover) noCover.push(r.item.title); if (r.noAuthor) noAuthor.push(r.item.title); }
} catch (x) { failed.push(f.name + ': ' + x.message); }
q('#sbp').style.width = (++done / files.length * 100) + '%';
}
const list = (t, a) => a.length ? `<details style="margin:6px 0"><summary>${t} (${a.length})</summary><div class="sub" style="max-height:25dvh;overflow:auto;word-break:break-word">${a.map(esc).join('<br>')}</div></details>` : '';
q('#sbn').textContent = `${stop ? 'Stopped. ' : ''}Added ${added.length} of ${files.length} books`; q('#sbl').textContent = '';
q('#sbr').innerHTML = list('Already in the store, skipped', dups) + list('No cover found', noCover) + list('No author found', noAuthor) + list('Could not be added', failed) + ((noCover.length || noAuthor.length) ? '<p class="sub">Open a book and tap Edit to fix its author or cover.</p>' : '');
q('#sbx').textContent = 'Done'; done = files.length;
try { await stLoad(); } catch {} redraw();
}
// Give the chosen books to people: they land on each person's shelf (a person without the Books tab is skipped, and a book they already have is left alone).
async function stGive(ids, after) {
let people; try { people = await stApi('/people'); } catch (e) { return toast(e.message); }
const el = openSheet('Add to people', ids.length + (ids.length === 1 ? ' book' : ' books'), `<div class="list" style="max-height:50dvh;overflow:auto">${people.map(p => `<label class="row" style="cursor:pointer"><input type="checkbox" data-u="${esc(p.id)}"${p.books ? '' : ' disabled'}><span class="grow"><b>${esc(p.name)}</b>${p.books ? '' : '<small>No Books tab</small>'}</span></label>`).join('')}</div>
<div class="err" id="sterr"></div><div class="bar"><button class="btn ghost" id="sgx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="sgok" style="flex:1;justify-content:center">Add</button></div>`);
el.querySelector('#sgx').onclick = closeSheet;
el.querySelector('#sgok').onclick = async e => {
const us = [...el.querySelectorAll('[data-u]:checked')].map(c => c.dataset.u), err = m => { el.querySelector('#sterr').textContent = m; }; if (!us.length) return err('Choose at least one person');
e.currentTarget.disabled = true; e.currentTarget.textContent = 'Adding…';
try { const r = await stApi('/assign', stOpt('POST', { items: ids, users: us })); closeSheet(); const n = r.people.reduce((a, p) => a + p.added, 0), had = r.people.reduce((a, p) => a + p.had, 0), bad = r.people.reduce((a, p) => a + p.failed, 0);
toast(`Added ${n} ${n === 1 ? 'book' : 'books'} to ${r.people.filter(p => p.added).length} ${r.people.filter(p => p.added).length === 1 ? 'person' : 'people'}` + (had ? `, ${had} already had it` : '') + (bad ? `, ${bad} failed` : '')); after();
} catch (x) { err(x.message); e.currentTarget.disabled = false; e.currentTarget.textContent = 'Add'; }
};
}
+104 -5
View File
@@ -146,8 +146,8 @@ body.editing #view { padding: 0; display: flex; flex-direction: column; }
/* Music */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.seg { display: flex; gap: 6px; margin: 12px 0; overflow-x: auto; }
.seg a { padding: 8px 16px; border-radius: 999px; background: var(--panel2); color: var(--muted); white-space: nowrap; min-height: 40px; display: flex; align-items: center; }
.seg { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
.seg a { padding: 8px 14px; border-radius: 999px; background: var(--panel2); color: var(--muted); white-space: nowrap; min-height: 40px; display: flex; align-items: center; }
.seg a.on { background: var(--accent); color: var(--accent-t); font-weight: 600; }
.agrid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.acard { display: block; min-width: 0; }
@@ -659,8 +659,9 @@ body.nav-mini .ngrp span { display: none; }
.vspin i { display: block; width: 44px; height: 44px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .3); border-top-color: #fff; animation: vspin 1s linear infinite; } @keyframes vspin { to { transform: rotate(360deg); } }
#vnextbox { position: absolute; right: 12px; bottom: 96px; z-index: 5; max-width: calc(100% - 24px); }
#vnextbox .vnext { background: rgba(20, 22, 28, .92); color: #fff; margin: 0; }
.vstage.vfull { position: fixed; inset: 0; z-index: 90; border-radius: 0; }
.vstage.vfull video, .vstage:fullscreen video { height: 100dvh; max-height: none; object-fit: contain; }
.vstage.vfull { position: fixed; inset: 0 0 var(--playerh, 0px) 0; z-index: 90; border-radius: 0; }
body.vfullwin #player { position: fixed; left: 0; right: 0; bottom: 0; z-index: 91; }
.vstage.vfull video, .vstage:fullscreen video { height: calc(100dvh - var(--playerh, 0px)); max-height: none; object-fit: contain; }
.vstage:fullscreen { border-radius: 0; background: #000; width: 100%; height: 100%; }
.vstage:fullscreen video { height: 100%; }
@media (max-width: 560px) { .vright input[type=range], #vmute { display: none; } .vb { min-width: 40px; } .vb.txt { font-size: 13px; padding: 0 6px; } }
@@ -705,7 +706,6 @@ body.nav-mini .ngrp span { display: none; }
.vmb .vskip em { font-size: 8px; top: 6px; }
/* The video's controls live in the bottom bar (like Music); the overlay strip is only for full screen */
.vstage:not(:fullscreen):not(.vfull) .vctl { display: none; }
.vtop .vright { display: flex; align-items: center; gap: 2px; margin-left: auto; }
.vtop .vtitle { flex: 0 1 auto; max-width: 38%; }
.vtop .vtc { flex: 0 1 auto; }
@@ -1112,3 +1112,102 @@ html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-
.otplbox { margin: 12px 0 0; } .otplbox summary { cursor: pointer; padding: 10px 2px; list-style: none; } .otplbox summary::-webkit-details-marker { display: none; } .otplbox summary::before { content: '▸ '; color: var(--accent); } .otplbox[open] summary::before { content: '▾ '; } .otplbox summary small { color: var(--muted); margin-left: 6px; }
.otpl { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .octile.otc small { white-space: normal; }
.sgin { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; }
/* Now Playing sheet: cover, title, seek, controls, and a row of extras */
#sheet .sheet.npsheet h3:empty { display: none; }
#sheet .sheet.npsheet { padding-top: 14px; }
.np .art { width: min(68vw, 320px); border-radius: 14px; margin: 4px auto 18px; box-shadow: 0 18px 40px rgba(0, 0, 0, .45), 0 2px 6px rgba(0, 0, 0, .3); }
.np .npmeta { padding: 0 8px; }
.np .npmeta h3 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: -.01em; word-break: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.np .npmeta .sub { margin: 4px 0 0; font-size: 14px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.np .npseek { margin: 16px 0 0; }
.np .ctl { gap: 8px; margin: 6px 0 14px; }
.np .ctl .ibtn.big { width: 64px; height: 64px; background: var(--accent); color: var(--accent-t); border-radius: 50%; }
.np .npchips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; padding-top: 14px; border-top: 1px solid var(--line); }
.npchip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: var(--text, inherit); font: inherit; font-size: 13px; cursor: pointer; transition: background .15s; }
.npchip:hover { background: var(--panel2); } .npchip svg { width: 16px; height: 16px; } .npchip[hidden] { display: none; }
.npchip.on { color: var(--danger); border-color: currentColor; }
/* full screen: art and words side by side, the player along the bottom */
#lyr .fsside, #lyr .fsbar, #lyr .fsonly { display: none; }
#lyr.fs { display: grid; grid-template-columns: minmax(300px, 42%) 1fr; grid-template-rows: auto 1fr auto auto; }
#lyr.fs .lyrtop { grid-column: 1 / -1; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: hidden; }
#lyr.fs .fsside { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; padding: 0 3vw; min-height: 0; text-align: center; grid-column: 1; grid-row: 2; }
#lyr.fs .fsimg { width: min(100%, 46vh, 460px); aspect-ratio: 1; object-fit: cover; border-radius: 16px; background: var(--panel2); box-shadow: 0 24px 60px rgba(0, 0, 0, .55); margin-bottom: 18px; }
#lyr.fs .fsside b { font-size: clamp(20px, 2.4vw, 30px); letter-spacing: -.01em; } #lyr.fs .fsside small { color: var(--muted); font-size: 15px; }
#lyr.fs .lyrbody { grid-column: 2; grid-row: 2; padding: 20vh 4vw 26vh 2vw; text-align: left; } #lyr.fs.kar .lyrbody { padding: 8vh 4vw 8vh 2vw; }
#lyr.fs .fsbar { display: flex; grid-column: 1 / -1; align-items: center; gap: 12px; padding: 8px max(4vw, 20px) 0; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
#lyr.fs .fsbar input { flex: 1; accent-color: var(--accent); }
#lyr.fs .lyrfoot { grid-column: 1 / -1; border-top: 0; background: transparent; justify-content: center; padding-top: 4px; }
#lyr.fs .lyrfoot .grow { flex: 0; } #lyr.fs .fsonly { display: inline-grid; }
@media (max-width: 760px) { #lyr.fs { display: flex; } #lyr.fs .fsside { display: none; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: visible; } #lyr.fs .lyrbody { padding: 24vh 20px 30vh; text-align: center; } #lyr.fs .fsbar { display: none; } }
/* no lyrics: the queue fills the space */
.lyrq { max-width: 560px; margin: 18px auto 0; text-align: left; }
.lyrq h4 { margin: 0 0 8px; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.lyrqrow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.lyrqrow:hover { background: color-mix(in srgb, var(--panel2) 70%, transparent); } .lyrqrow.now { background: var(--panel2); }
.lyrqrow .num { width: 22px; text-align: center; color: var(--muted); font-size: 13px; flex: none; } .lyrqrow.now .num { color: var(--accent); }
.lyrqrow .grow { min-width: 0; } .lyrqrow b, .lyrqrow small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lyrqrow small { color: var(--muted); font-size: 12px; }
#lyr .lyrbody:has(.lyrq) { padding-top: 10vh; padding-bottom: 16vh; } #lyr .lyrbody:has(.lyrq) .empty { padding-top: 0; text-align: center; }
.mini .vbvol { width: 84px; accent-color: var(--accent); } .mini .ibtn.txt { width: auto; min-width: 40px; font-size: 13px; font-weight: 600; padding: 0 6px; }
/* full screen: the bottom bar slides away with the other controls after a few idle seconds */
body.vfullwin #player { transition: transform .25s ease, opacity .25s; }
body.vfullwin:has(.vstage.vhide) #player { transform: translateY(100%); opacity: 0; pointer-events: none; }
body.vfullwin:has(.vstage.vhide) .vstage.vfull { bottom: 0; } body.vfullwin:has(.vstage.vhide) .vstage.vfull video { height: 100dvh; }
/* album and playlist pages on a phone: Play and Shuffle are the two big buttons, the rest sit under them */
.mprime .btn { flex: 1; justify-content: center; min-height: 48px; font-size: 16px; }
.msecond { margin-top: -4px; } .msecond:empty { display: none; }
.msecond .btn { min-height: 40px; font-size: 14px; }
/* playlist cover: the songs' art in a grid, like Plex */
.mosaic { display: grid; grid-template-columns: 1fr 1fr; width: 100%; aspect-ratio: 1; border-radius: 12px; overflow: hidden; background: var(--panel2); }
.mosaic img { width: 100%; height: 100%; object-fit: cover; display: block; } .mosaic.one { grid-template-columns: 1fr; }
.mhead .mosaic { width: 38vw; max-width: 200px; flex: none; border-radius: 14px; } .acard .mixart { display: block; }
/* Now Playing sheet: the close button sits over the corner instead of pushing the art aside */
#sheet .sheet.npsheet .sheetx { float: none; display: grid; place-items: center; margin: 0 0 -36px auto; background: rgba(0, 0, 0, .45); backdrop-filter: blur(6px); color: #fff; }
.seg.segphone { display: none; }
@media (max-width: 640px) { .seg.segwide { display: none; } .seg.segphone { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; } .seg.segphone a { padding: 0 2px; justify-content: center; font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; } }
/* the bottom bars (music and video): a playhead you can see and grab, at the end of the played part */
.mini .bar2 { overflow: visible; }
.mini .bar2 i::after { content: ''; position: absolute; right: -8px; top: 50%; width: 16px; height: 16px; margin-top: -8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent), 0 1px 4px rgba(0, 0, 0, .5); transition: transform .12s; pointer-events: none; }
.mini .bar2:active i::after { transform: scale(1.35); }
.mini .bar2::before { top: -14px; bottom: -12px; }
.mini .bar2[hidden] i::after { display: none; }
/* the queue docked down the right side on wide screens */
#qdock { position: fixed; right: 0; top: 0; width: 340px; display: flex; flex-direction: column; background: var(--panel); border-left: 1px solid var(--line); z-index: 40; }
body.qdock #view { padding-right: 340px; }
#qdock .qhead { display: flex; align-items: center; gap: 8px; padding: 14px 8px 10px 16px; } #qdock .qhead b { font-size: 16px; } #qdock .qhead small { color: var(--muted); flex: 1; }
#qdock .qlist { flex: 1; overflow-y: auto; padding: 0 8px; }
#qdock .qrow { display: flex; align-items: center; border-radius: 10px; } #qdock .qrow:hover { background: var(--panel2); } #qdock .qrow.now { background: var(--panel2); }
#qdock .qmain { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 8px 6px; background: none; border: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; }
#qdock .num { width: 22px; text-align: center; color: var(--muted); font-size: 13px; flex: none; } #qdock .qrow.now .num, #qdock .qrow.now b { color: var(--accent); }
#qdock .grow { min-width: 0; } #qdock b, #qdock small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #qdock .qmain small { color: var(--muted); font-size: 12px; }
#qdock .qfoot { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); } #qdock .qfoot .btn { flex: 1; justify-content: center; }
/* touch screens (iPad): pressing and holding does not select text or open the image menu, and a double tap does not zoom.
Text you would want to copy (mail, notes, messages, the page text in the reader) and every text field stay selectable. */
@media (pointer: coarse) {
html, body { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; touch-action: manipulation; -webkit-tap-highlight-color: transparent; }
input, textarea, select, [contenteditable], .mbody, .mbody *, .ntbody, .ntbody *, .sgmsgs, .sgmsgs *, .textLayer, .textLayer *, .selectable, .selectable * { -webkit-user-select: text; user-select: text; -webkit-touch-callout: default; }
img, svg, a, button { -webkit-touch-callout: none; -webkit-user-drag: none; }
input, textarea, select { font-size: max(16px, 1em); } /* (iPadOS zooms the page when a smaller field is focused) */
}
/* My Store: books ticked for "Add to people" */
.vcard.picked { outline: 3px solid var(--accent); outline-offset: 2px; border-radius: 12px; }
/* "Loading…" placeholders: a soft shimmer so a slow tab reads as working, not stuck (still text for screen readers; static when motion is reduced) */
.empty.loading { background: linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--muted) 16%, transparent) 50%, transparent 100%) 0 0 / 200% 100% no-repeat; animation: loadsweep 1.4s ease-in-out infinite; border-radius: 12px; }
@keyframes loadsweep { from { background-position: 150% 0; } to { background-position: -50% 0; } }
@media (prefers-reduced-motion: reduce) { .empty.loading { animation: none; } }
/* Photo viewer for Signal and Chat images (image-viewer.js) */
#imgview { position: fixed; inset: 0; z-index: 400; background: rgba(0, 0, 0, .92); display: flex; align-items: center; justify-content: center; padding: calc(var(--sat, 0px) + 56px) 12px calc(var(--sab, 0px) + 12px); }
#imgview img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; touch-action: pinch-zoom; -webkit-touch-callout: default; user-select: auto; }
#imgview .ivx { position: absolute; top: calc(var(--sat, 0px) + 8px); right: 12px; background: rgba(255, 255, 255, .16); color: #fff; }
#imgview .ivsave { position: absolute; top: calc(var(--sat, 0px) + 16px); left: 16px; color: #fff; font-size: 16px; padding: 6px 12px; border-radius: 16px; background: rgba(255, 255, 255, .16); text-decoration: none; cursor: pointer; }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v134';
const CACHE = 'hub-v141';
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
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'];
+3 -3
View File
@@ -7,7 +7,7 @@ let taskLists = []; // the lists (NE Hub and iCloud), so a task seen in "All" op
async function viewTasks() {
const v = $('#view');
v.innerHTML = '<div class="wrap">' + plannerHead('tasks') + '<p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap">' + plannerHead('tasks') + '<p class="empty loading" role="status">Loading…</p></div>';
try {
let { lists, icloud } = await jfetch('/api/tasks/lists');
if (lists.some(l => l.mealie) && allowed('recipes')) { try { await jfetch('/api/recipes/shopping/sync', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); } catch {} } // (the Shopping list is shared with Mealie: bring it up to date first)
@@ -18,7 +18,7 @@ async function viewTasks() {
v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
<div class="seg"><a href="#" data-l="today" class="${cur === 'today' ? 'on' : ''}">Today</a><a href="#" data-l="upcoming" class="${cur === 'upcoming' ? 'on' : ''}">Upcoming</a>${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${['all', 'today', 'upcoming'].includes(cur) ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
<div id="tl"><p class="empty">Loading…</p></div>
<div id="tl"><p class="empty loading" role="status">Loading…</p></div>
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
: icloud.reminderLists === 0 ? 'Your iCloud account has no reminder lists that apps can reach. Apple hides Reminders lists that were upgraded for iOS 13 and later, so tasks here are kept in NE Hub.' : '';
@@ -159,7 +159,7 @@ function chooseFolder(title, start, opts = {}) {
el.querySelector('.scrim').addEventListener('click', () => { if (!done) resolve(null); });
const finish = v => { done = true; closeSheet(); resolve(v); };
const draw = async () => {
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty">Loading…</p>';
const box = el.querySelector('#cf'); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
try {
if (!repo) {
const repos = (await sf.req('/api2/repos/')).filter(r => !r.encrypted && r.permission !== 'r').sort((a, b) => a.name.localeCompare(b.name));
+1 -1
View File
@@ -20,7 +20,7 @@ const vbDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLoca
const vbCover = (s, big) => `<span class="vimg" style="aspect-ratio:1/1;background:var(--panel2)"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(stOk(s.cover))}" onerror="this.remove()"></span>`;
async function viewVbv(args = []) {
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Verse By Verse</h1><p class="empty">Loading…</p></div>';
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Verse By Verse</h1><p class="empty loading" role="status">Loading…</p></div>';
try { await vbLoadProg(); if (args[0] === 'study' && args[1]) return await vbStudy(args[1]); return await vbHome(); }
catch (e) { if (!location.hash.startsWith('#/podcast/vbv')) return; v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/podcast">${icon('back')}Podcast</a><h1>Verse By Verse</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="vbretry">Try again</button><a class="btn ghost" href="https://versebyverseministry.org" target="_blank" rel="noopener">Open the website</a></div></div>`; $('#vbretry').onclick = () => route(); }
}
+31 -36
View File
@@ -29,7 +29,7 @@ async function vdNav(m, back) {
}
async function viewVideo(args = []) {
const v = $('#view'), [kind, key] = args;
v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">Loading…</p></div>';
v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty loading" role="status">Loading…</p></div>';
try {
const st = await jfetch('/api/plex/status');
if (!location.hash.startsWith('#/video')) return;
@@ -118,7 +118,7 @@ async function videoHome(st, libKey) {
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
<input type="search" id="vq" placeholder="Search titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
<div id="vhero"></div><div id="vdeck"></div><div id="vmix" class="seg"></div><div id="vnight"></div><div id="vwlr"></div><div id="vfavr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty loading" role="status">Loading…</p></div><div id="vmore"></div></div>`;
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
@@ -310,38 +310,23 @@ function videoMount() {
const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage">
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b>
<div class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', vp.speed + '×', 'Speed', ' txt')}${castOk(vp.el) ? b('vcast', 'Cast', 'Play on a TV or speaker', ' txt') : ''}${b('vset', 'Quality & tracks', 'Quality and tracks', ' txt')}${document.pictureInPictureEnabled ? b('vpip', icon('pip'), 'Picture in picture') : ''}${b('vfs', icon('full'), 'Full screen')}${b('vclose', icon('x'), 'Close the video')}</div></div>
</div>
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
<div class="vctl"><div class="vseek"><span id="vcur">0:00</span><input type="range" id="vrange" min="0" max="1000" step="1" value="0" aria-label="Seek"><span id="vrem">-0:00</span></div>
<div class="vrow2"><div class="vleft">${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `<span class="vskip">${icon('rew')}<em>10</em></span>`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `<span class="vskip">${icon('fwd')}<em>10</em></span>`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}</div></div></div>
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
const stage = $('#vstage'); stage.prepend(el); el.controls = false;
$('#vvol').value = Math.round(el.volume * 100);
const total = () => (vp.m.duration || 0) / 1000;
let drag = false, hideT = null;
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => stage.classList.add('vhide'), 3000); };
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => { if (document.querySelector('#player:hover, #player:focus-within')) wake(); else stage.classList.add('vhide'); }, 3000); }; // (in full screen the bottom bar hides with the rest, but not while you are on it)
vp.wake = wake; vp.drag = () => drag;
vp.flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
$('#vpp').onclick = () => { el.paused ? el.play().catch(() => {}) : el.pause(); };
$('#vb10').onclick = () => videoSkip(-10); $('#vf10').onclick = () => videoSkip(10);
if ($('#vprev')) $('#vprev').onclick = () => videoGo(-1); if ($('#vnextep')) $('#vnextep').onclick = () => videoGo(1);
if ($('#vcast')) $('#vcast').onclick = () => castTo(vp && vp.el);
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
$('#vmute').onclick = () => { el.muted = !el.muted; };
$('#vclose').onclick = () => { const back = $('#vback').getAttribute('href'); videoStop(); location.hash = back; };
$('#vvol').oninput = e => { el.volume = e.target.value / 100; el.muted = el.volume === 0; store.set('video_vol', el.volume); };
$('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el.playbackRate = r; $('#vspd').textContent = r + '×'; } })));
const range = $('#vrange');
range.oninput = () => { drag = true; vdUi(); wake(); }; range.onchange = () => { const t = total() || el.duration || 0; videoSeek(range.value / 1000 * t); drag = false; wake(); };
let lastTap = 0, lastDbl = 0;
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
stage.addEventListener('pointerup', e => {
if (e.target.closest('.vctl, .vtop, #vnextbox, .vmark')) return;
if (e.target.closest('.vtop, #vnextbox, .vmark')) return;
if (e.pointerType === 'touch') {
const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width;
if (now - lastTap < 320 && (x < .35 || x > .65)) { videoSkip(x < .5 ? -10 : 10); lastTap = 0; wake(); return; }
lastTap = now; if (stage.classList.contains('vhide')) wake(); else { stage.classList.add('vhide'); clearTimeout(hideT); }
const wasHidden = vp.hidAtDown !== undefined ? vp.hidAtDown : stage.classList.contains('vhide'); vp.hidAtDown = undefined; // (the touch itself already woke the bar in full-window mode, so ask what it was when the finger went down)
lastTap = now; if (wasHidden) wake(); else { stage.classList.add('vhide'); clearTimeout(hideT); }
} else { if (e.detail === 2) { videoFullscreen(); return; } setTimeout(() => { if (Date.now() - lastDbl > 350) { el.paused ? el.play().catch(() => {}) : el.pause(); } }, 0); wake(); }
});
stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake);
@@ -414,11 +399,21 @@ function videoBar() {
el.innerHTML = `<div class="mini vbar"><div class="bar2" id="vbseek" role="slider" aria-label="Seek"><i id="vbp"></i></div><img class="art" alt="" src="${vdArt(m.thumb || m.parentThumb || m.grandparentThumb, 100, 150)}" onerror="this.style.visibility='hidden'">
<button class="meta" id="vbo" aria-label="Open the player"><b>${esc(ep ? m.title : m.title)}</b><small>${esc(ep ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index}` : [m.year, 'Movie'].filter(Boolean).join(' · '))}</small></button><span class="mtime" id="vbtm"></span>
${ep ? `<button class="ibtn wide-only" id="vbprev" aria-label="Previous episode" title="Previous episode">${icon('prev')}</button>` : ''}<button class="ibtn" id="vbb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="ibtn" id="vbpp" aria-label="Play or pause"></button><button class="ibtn" id="vbf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button>${ep ? `<button class="ibtn wide-only" id="vbnext" aria-label="Next episode" title="Next episode">${icon('next')}</button>` : ''}
<button class="ibtn wide-only" id="vmute" aria-label="Mute" title="Mute">${icon(vp.el.muted || vp.el.volume === 0 ? 'mute' : 'vol')}</button><input class="wide-only vbvol" type="range" id="vvol" min="0" max="100" value="${Math.round(vp.el.volume * 100)}" aria-label="Volume">
<button class="ibtn wide-only txt" id="vspd" aria-label="Playback speed" title="Playback speed">${vp.speed}×</button>${castOk(vp.el) ? `<button class="ibtn wide-only" id="vcast" aria-label="Play on a TV or speaker" title="Play on a TV or speaker">${icon('ext')}</button>` : ''}
<button class="ibtn" id="vset" aria-label="Quality and tracks" title="Quality and tracks">${icon('cog')}</button>${document.pictureInPictureEnabled ? `<button class="ibtn wide-only" id="vpip" aria-label="Picture in picture" title="Picture in picture">${icon('pip')}</button>` : ''}<button class="ibtn" id="vfs" aria-label="Full screen" title="Full screen">${icon('full')}</button>
<button class="ibtn wide-only" id="vbx" aria-label="Close the video" title="Close the video">${icon('x')}</button></div>`;
const q = s => el.querySelector(s);
q('#vbpp').onclick = () => { const v = vp && vp.el; if (v) v.paused ? v.play().catch(() => {}) : v.pause(); };
q('#vbb10').onclick = () => videoSkip(-10); q('#vbf10').onclick = () => videoSkip(10);
if (q('#vbprev')) q('#vbprev').onclick = () => videoGo(-1); if (q('#vbnext')) q('#vbnext').onclick = () => videoGo(1);
const el0 = vp.el;
q('#vmute').onclick = () => { el0.muted = !el0.muted; };
q('#vvol').oninput = e => { el0.volume = e.target.value / 100; el0.muted = el0.volume === 0; store.set('video_vol', el0.volume); };
q('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el0.playbackRate = r; q('#vspd').textContent = r + '×'; } })));
if (q('#vcast')) q('#vcast').onclick = () => castTo(vp && vp.el);
q('#vset').onclick = videoSettings; q('#vfs').onclick = videoFullscreen;
if (q('#vpip')) q('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el0.requestPictureInPicture(); } catch {} };
q('#vbo').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
q('#vbx').onclick = () => { const onPlayer = location.hash.startsWith('#/video/watch/'); const back = onPlayer && $('#vback') ? $('#vback').getAttribute('href') : null; videoStop(); if (back) location.hash = back; };
// drag along the line: the position and time follow your finger at once, and the video jumps once when you let go (a seek on every move would make a video stutter)
@@ -467,19 +462,19 @@ function videoGo(dir) {
/** Full screen: the browser's own full screen when it allows it (the player and its buttons stay), the phone's video full screen on iPhone,
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
function videoFullscreen() {
if (!vp) return; const box = document.querySelector('.vstage'), el = vp.el; if (!box) return;
const native = document.fullscreenElement || document.webkitFullscreenElement;
if (native) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); return; }
if (box.classList.contains('vfull')) { box.classList.remove('vfull'); document.body.classList.remove('vfullwin'); return; }
const req = box.requestFullscreen || box.webkitRequestFullscreen;
const fallback = () => { if (el.webkitEnterFullscreen && /iPhone|iPad|iPod/.test(navigator.userAgent)) { try { el.webkitEnterFullscreen(); return; } catch {} } box.classList.add('vfull'); document.body.classList.add('vfullwin'); };
if (!req) return fallback();
try { // some embedded browsers accept the request and then do nothing: if full screen has not started a moment later, use the full-window mode
const p = req.call(box); if (p && p.catch) p.catch(fallback);
setTimeout(() => { if (!document.fullscreenElement && !document.webkitFullscreenElement && !box.classList.contains('vfull') && vp) fallback(); }, 450);
} catch { fallback(); }
if (!vp) return; const box = document.querySelector('.vstage'); if (!box) return;
if (box.classList.contains('vfull')) return videoFsExit();
box.classList.add('vfull'); document.body.classList.add('vfullwin'); // (the picture fills the window; the bottom bar stays under it with the controls)
const root = document.documentElement, req = root.requestFullscreen || root.webkitRequestFullscreen;
if (req) { try { const p = req.call(root); if (p && p.catch) p.catch(() => {}); } catch {} } // (if the browser refuses, the full-window mode above is what you get)
}
document.addEventListener('fullscreenchange', () => { const b = $('#vfs'); if (b) b.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen'; });
function videoFsExit() {
const box = document.querySelector('.vstage.vfull'); if (box) box.classList.remove('vfull'); document.body.classList.remove('vfullwin');
if (document.fullscreenElement || document.webkitFullscreenElement) { try { (document.exitFullscreen || document.webkitExitFullscreen).call(document); } catch {} }
}
// in full screen, touching or moving over the picture or the bar brings the hidden bar back
['pointermove', 'pointerdown', 'keydown'].forEach(ev => addEventListener(ev, () => { if (vp && vp.wake && document.body.classList.contains('vfullwin')) { if (ev === 'pointerdown') vp.hidAtDown = !!document.querySelector('.vstage.vhide'); vp.wake(); } }, true));
document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && document.body.classList.contains('vfullwin')) videoFsExit(); });
/** The picture stopped on its own: start again from where it was, at most 4 times in a row, and say so. */
function videoRescue(why, at) {
if (!vp) return false; const now = Date.now(); if (now - (vp.rescueAt || 0) > 90000) vp.rescues = 0; if ((vp.rescues || 0) >= 4) return false;
@@ -568,7 +563,7 @@ addEventListener('keydown', e => {
else if (k === 'ArrowUp') { e.preventDefault(); el.volume = Math.min(1, el.volume + .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
else if (k === 'ArrowDown') { e.preventDefault(); el.volume = Math.max(0, el.volume - .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
else if (k === 'f') { e.preventDefault(); videoFullscreen(); }
else if (k === 'Escape') { const b = document.querySelector('.vstage.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } }
else if (k === 'Escape') videoFsExit();
else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1);
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
}, true);
@@ -591,7 +586,7 @@ async function videoEdit(m) {
el.querySelector('#sx').onclick = closeSheet;
const loaded = {};
const images = async (tab, what) => { // posters / backgrounds: pick one, or add from an image link
const box = el.querySelector('#vtab-' + tab); box.innerHTML = '<p class="empty">Loading…</p>';
const box = el.querySelector('#vtab-' + tab); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
try {
const mc = await px.get(`/library/metadata/${m.ratingKey}/${what}`), list = mc.Metadata || [];
box.innerHTML = `<div class="vimgs ${what}">${list.map((x, i) => `<button type="button" class="vpick ${x.selected ? 'on' : ''}" data-i="${i}" aria-label="Use this image"><img alt="" loading="lazy" src="${/^https?:/.test(x.thumb || '') ? esc(x.thumb) : vdArt(x.thumb, 200, 300)}"></button>`).join('') || '<p class="empty">No images yet.</p>'}</div>
+13 -3
View File
@@ -36,13 +36,23 @@ function localNow(tz, at = new Date()) {
return { day: `${p.year}-${p.month}-${p.day}`, hm: `${p.hour}:${p.minute}` };
}
// Since the server started: how many pushes went out, and what the push service last refused (shown to the admin in Settings > Admin)
const stats = { sent: 0, failed: 0, expired: 0, lastOk: 0, lastErr: '', lastErrAt: 0 };
function health() {
let devices = 0, people = 0;
for (const u of users.all()) { if (u.disabled) continue; const n = load(users.dirOf(u.id)).subs.length; devices += n; if (n) people++; }
return { ...stats, devices, people };
}
async function send(dir, st, uid, payload) {
if (uid) inbox.add(uid, { kind: 'remind', title: payload.title, body: payload.body, url: payload.url, tag: payload.tag, ref: payload.ref }); // (reminders also land in the Notifications list)
keys(); const { ref, ...wire } = payload;
let changed = false;
await Promise.all(st.subs.map(async s => {
try { await webpush.sendNotification(s.sub, JSON.stringify(wire), { TTL: 3600 }); }
catch (e) { if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; } else console.warn('[push] send failed:', e.statusCode || e.message); }
try { await webpush.sendNotification(s.sub, JSON.stringify(wire), { TTL: 3600 }); stats.sent++; stats.lastOk = Date.now(); }
catch (e) {
if (e.statusCode === 404 || e.statusCode === 410) { s.dead = true; changed = true; stats.expired++; }
else { stats.failed++; stats.lastErr = String(e.statusCode || e.message).slice(0, 120); stats.lastErrAt = Date.now(); console.warn('[push] send failed:', e.statusCode || e.message); }
}
}));
if (changed) { st.subs = st.subs.filter(s => !s.dead); save(dir, st); }
}
@@ -182,4 +192,4 @@ async function handle(req, res) {
}
/** A notification for one person's devices (a chat message, say), unless they turned that kind off. */
async function notify(uid, payload) { const dir = users.dirOf(uid), st = load(dir); if (!st.subs.length || st.chat === false) return; await send(dir, st, null, payload); }
module.exports = { handle, start, tick, localNow, notify };
module.exports = { handle, start, tick, localNow, notify, health };
+1
View File
@@ -687,6 +687,7 @@ async function handleRequest(req, res) {
const checks = [probe('Seafile (Files)', SEAFILE_URL + '/api2/ping/'), probe('OnlyOffice', ONLYOFFICE_URL + '/healthcheck'), probe('Audiobooks', (process.env.OPENAUDIBLE_URL || AUDIOBOOKS_URL).replace(/\/$/, '') + '/'), probe('Podcasts (Podgrab)', PODCAST_URL)];
if (recipes.enabled()) checks.push(probe('Recipes (Mealie)', (process.env.MEALIE_URL || 'https://mealie.necloud.us').replace(/\/+$/, '') + '/api/app/about'));
out.services = await Promise.all(checks);
try { out.push = require('./push').health(); } catch { out.push = null; }
out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); out.transcripts = transcripts.status();
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out));
}
+1 -1
View File
@@ -9,7 +9,7 @@ const users = require('./users');
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
// Only these keys are accepted. (The music queue stays in the browser.)
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'music_feat_pls', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']);
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'music_feat_pls', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups', 'song_cache']);
const SECRET = new Set(['ab_creds', 'sf_token']);
const MAX_VALUE = 20000; // bytes of JSON per key
+2 -2
View File
@@ -180,7 +180,7 @@ async function handle(req, res) {
const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 };
const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; });
count(load().tasks);
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) count(parseTodos(r.ics)); } catch {} }
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) count(parseTodos(r.ics)); } catch (e) { console.warn('[tasks] could not read the iCloud list ' + l.name + ':', e.message); } }
return json(res, 200, out);
}
json(res, 404, { error: 'Not found' });
@@ -191,7 +191,7 @@ async function handle(req, res) {
async function allTasks() {
const d = load(), out = [];
for (const t of d.tasks) { const l = d.lists.find(x => x.id === t.list); out.push({ id: t.id, list: 'local:' + t.list, listName: l ? l.name : 'Tasks', title: t.title, notes: t.notes || '', due: t.due || '', dueTime: t.dueTime || '', done: !!t.done, repeat: t.repeat || 'none', parent: t.parent || '', by: t.by || '' }); }
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch {} }
for (const l of caldav.todoLists()) { try { for (const r of await caldav.listTodoRaw(l.id)) for (const t of parseTodos(r.ics)) out.push({ ...t, list: l.id, listName: l.name, href: r.href, etag: r.etag }); } catch (e) { console.warn('[tasks] could not read the iCloud list ' + l.name + ':', e.message); } }
return out;
}
+18
View File
@@ -24,6 +24,24 @@ const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] ==
r = await fetch(`${base}/api/store/my/items/${book.id}/files/${book.files[0].id}`, { headers: bob }); assert.equal(r.status, 200); assert.match(r.headers.get('content-disposition'), /attachment/); assert.equal(Buffer.from(await r.arrayBuffer()).length, epub.length);
r = await fetch(`${B}/items/${book.id}/to-library`, { method: 'POST', headers: bob }); const sh = await r.json(); assert.equal(r.status, 200); assert.equal(sh.title, 'My Own Book'); assert.equal(sh.cover, true);
assert.equal((await (await fetch(base + '/api/read/list', { headers: bob })).json()).length, 1, 'it is on Bob\'s shelf, not Ann\'s'); assert.equal((await (await fetch(base + '/api/read/list')).json()).length, 0);
// bulk import: title, author and cover are read out of the EPUB, a repeat is skipped, a plain file falls back to its name
const mk = (dir, name) => { cp.execFileSync('python3', ['-c', `import zipfile,sys;z=zipfile.ZipFile(sys.argv[1],'w');z.writestr('mimetype','application/epub+zip',zipfile.ZIP_STORED);z.writestr('META-INF/container.xml','<container><rootfiles><rootfile full-path="OEBPS/c.opf" media-type="application/oebps-package+xml"/></rootfiles></container>');z.writestr('OEBPS/c.opf','<package><metadata xmlns:dc="x"><dc:title>Caf&#233; &amp; Cream</dc:title><dc:creator>Ann Author</dc:creator><dc:creator>Bo Two</dc:creator><meta name="cover" content="img"/></metadata><manifest><item id="img" href="img/cv.png" media-type="image/png"/></manifest></package>');z.writestr('OEBPS/img/cv.png',b'\\x89PNG'+b'0'*900);z.close()`, path.join(dir, name)]); return fs.readFileSync(path.join(dir, name)); };
const withCover = mk(fx, 'cover.epub'), imp = (n, b, h = {}) => put('/import?name=' + encodeURIComponent(n), b, h);
assert.equal((await imp('a.epub', withCover, bob)).status, 403, 'only the admin imports');
assert.equal((await imp('a.exe', withCover)).status, 400);
let ir = await (await imp('whatever_file.epub', withCover)).json(); assert.equal(ir.item.title, 'Café & Cream'); assert.equal(ir.item.creator, 'Ann Author, Bo Two'); assert.equal(ir.item.cover, true); assert.equal(ir.noCover, false);
assert.equal((await fetch(`${B}/items/${ir.item.id}/cover`)).headers.get('content-type'), 'image/png');
assert.equal((await (await imp('again.epub', withCover)).json()).duplicate, true, 'the same book is not added twice');
ir = await (await imp('Plain_Name.epub', epub)).json(); assert.equal(ir.item.title, 'Rabbit Tales'); assert.equal(ir.noCover, true); assert.equal(ir.item.creator, 'A. Writer');
ir = await (await imp('Some_Pdf_Book.pdf', Buffer.from('%PDF-1.4 hello'))).json(); assert.equal(ir.item.title, 'Some Pdf Book'); assert.equal(ir.noAuthor, true);
// the admin gives chosen books to chosen people
const impd = (await (await fetch(B + '/items')).json()).items.filter(x => x.id !== book.id && x.kind === 'book').map(x => x.id);
assert.equal((await fetch(B + '/people', { headers: bob })).status, 403); const ppl = await (await fetch(B + '/people')).json(); assert.ok(ppl.some(x => x.id === people.b.id && x.books));
assert.equal((await post('/assign', { items: impd, users: [people.b.id] }, bob)).status, 403); assert.equal((await post('/assign', { items: [], users: [people.b.id] })).status, 400); assert.equal((await post('/assign', { items: impd, users: [] })).status, 400);
let as = await (await post('/assign', { items: impd, users: [people.b.id] })).json(); assert.equal(as.people[0].added + as.people[0].had, impd.length, 'one of them is the book Bob already has'); assert.ok(as.people[0].added >= 2); assert.equal(as.people[0].failed, 0);
as = await (await post('/assign', { items: impd, users: [people.b.id] })).json(); assert.equal(as.people[0].added, 0); assert.equal(as.people[0].had, impd.length, 'a repeat is left alone');
assert.ok((await (await fetch(base + '/api/read/list', { headers: bob })).json()).some(x => x.title === 'Café & Cream'), 'the book is on Bob\'s shelf');
for (const it of (await (await fetch(B + '/items')).json()).items.filter(x => x.id !== book.id && x.kind === 'book')) await fetch(`${B}/items/${it.id}`, { method: 'DELETE' });
// music: several files, streamed with ranges
let alb = await (await post('/items', { kind: 'music', title: 'Album', creator: 'Band' })).json(); const mp3 = Buffer.from('0123456789abcdefghij');
alb = await (await put(`/items/${alb.id}/files?name=01.mp3`, mp3)).json(); alb = await (await put(`/items/${alb.id}/files?name=02.mp3`, mp3)).json(); assert.equal(alb.files.length, 2);
+2
View File
@@ -78,6 +78,8 @@ const users = require('../users'), push = require('../push');
dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] }));
await push.tick(new Date());
ok('a device that has unsubscribed (410) is removed', JSON.parse(fs.readFileSync(path.join(d, 'push.json'), 'utf8')).subs.length === 0, fs.readFileSync(path.join(d, 'push.json'), 'utf8'));
const h = push.health();
ok('the admin health report counts the pushes sent and the expired device removed', h.sent > 0 && h.expired >= 1 && typeof h.devices === 'number' && typeof h.people === 'number', h);
fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll push checks passed'); process.exit(failed ? 1 : 0);
+16
View File
@@ -0,0 +1,16 @@
// Runs every tools/*-test.js (or only the names given), one after another, and lists the ones that fail.
// A new test file is picked up automatically; `smoke` and `frontend` run first because they fail fastest.
const { spawnSync } = require('child_process');
const fs = require('fs'), path = require('path');
const FIRST = ['smoke', 'frontend'];
const all = fs.readdirSync(__dirname).filter(f => f.endsWith('-test.js')).map(f => f.slice(0, -'-test.js'.length));
const want = process.argv.slice(2);
const names = (want.length ? want : all).sort((a, b) => (FIRST.indexOf(a) + 1 || 99) - (FIRST.indexOf(b) + 1 || 99) || a.localeCompare(b));
const failed = [];
for (const n of names) {
if (!all.includes(n)) { console.error('No test named ' + n); process.exit(2); }
console.log('\n== ' + n);
if (spawnSync(process.execPath, [path.join(__dirname, n + '-test.js')], { stdio: 'inherit' }).status !== 0) failed.push(n);
}
console.log(failed.length ? '\nFAILED: ' + failed.join(', ') : '\nAll ' + names.length + ' test files passed');
process.exit(failed.length ? 1 : 0);
+7
View File
@@ -0,0 +1,7 @@
// Every setting the app syncs must be one the server accepts, or the whole save fails.
const fs = require('fs'), assert = require('assert');
const client = fs.readFileSync(__dirname + '/../public/app.js', 'utf8'), server = fs.readFileSync(__dirname + '/../settings.js', 'utf8');
const list = s => [...s.matchAll(/'([a-z_]+)'/g)].map(m => m[1]);
const c = list(/const SYNCED = new Set\(\[([^\]]*)\]/.exec(client)[1]), s = new Set(list(/const KEYS = new Set\(\[([^\]]*)\]/.exec(server)[1]));
const missing = c.filter(k => !s.has(k)); assert.deepStrictEqual(missing, [], 'the server would refuse: ' + missing.join(', '));
console.log('settings keys: ' + c.length + ' synced settings, all accepted by the server');
+1 -1
View File
@@ -10,6 +10,6 @@ for (const k of ['12345', 'radio:journeyfm']) ok(ctx.vizKind({ key: k }) === 'mu
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(/id=\\"npv\\"|chip\('npv'/.test(rd('player.js')) && 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');