Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d173555398 | ||
|
|
4e26bea455 | ||
|
|
3376499805 | ||
|
|
272760c321 | ||
|
|
bbe32bae83 | ||
|
|
1293ff0673 | ||
|
|
94ad5a8e1d | ||
|
|
e248bac143 | ||
|
|
d025e250fe | ||
|
|
1a797c972e | ||
|
|
2b4da8d9a2 | ||
|
|
139ecf4ab9 | ||
|
|
45c4dbe120 | ||
|
|
b0d6473e4d | ||
|
|
0ae68d6842 | ||
|
|
fd5812c5c2 | ||
|
|
8b77bacb7a | ||
|
|
ee4674cdad | ||
|
|
1c6fff51bb | ||
|
|
3db4e4aa1e | ||
|
|
4f482a1e1a | ||
|
|
8b295dddef |
No files matched your search
@@ -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.
|
- **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.
|
- **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)
|
## 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
|
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.
|
**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.
|
- **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.
|
- **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.
|
- **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).
|
- **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
@@ -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 };
|
||||||
+34
@@ -7,11 +7,13 @@ const { pipeline } = require('stream/promises');
|
|||||||
const atomicWrite = require('./atomic');
|
const atomicWrite = require('./atomic');
|
||||||
const users = require('./users'), settings = require('./settings'), books = require('./books');
|
const users = require('./users'), settings = require('./settings'), books = require('./books');
|
||||||
const { readBody } = require('./body');
|
const { readBody } = require('./body');
|
||||||
|
const { readEpub } = require('./epubmeta');
|
||||||
|
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
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 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 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_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 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 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'];
|
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 (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 (!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') {
|
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');
|
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 });
|
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 === '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 (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 ((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');
|
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');
|
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
@@ -5,7 +5,7 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"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": "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 settingskeys 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",
|
||||||
"audit": "npm audit --omit=dev --audit-level=high"
|
"audit": "npm audit --omit=dev --audit-level=high"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
+4
-1
@@ -11,7 +11,7 @@ const $ = s => document.querySelector(s);
|
|||||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
|
// 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.
|
// 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;
|
const outbox = new Map(); let outTimer = null, warned = false;
|
||||||
function flushSettings() {
|
function flushSettings() {
|
||||||
outTimer = null;
|
outTimer = null;
|
||||||
@@ -55,6 +55,9 @@ async function loadSettings() {
|
|||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
||||||
|
|||||||
+6
-6
@@ -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').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)));
|
$('#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 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 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.userName] = per[c.userName] || []).push(c); });
|
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(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 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')
|
$('#grid').innerHTML = card('Chores today', chores || none('No chores today'), '', 'wide')
|
||||||
+ card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1), 'event')
|
+ 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')
|
+ 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() {
|
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>`);
|
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 {} }
|
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) {
|
function pick(p) {
|
||||||
@@ -49,7 +49,7 @@ function pick(p) {
|
|||||||
paint(); $('#no').onclick = close;
|
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(); });
|
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(); });
|
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
|
// ---- 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
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
// ---- Boot
|
// ---- 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 () => {
|
(async () => {
|
||||||
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
|
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
|
||||||
const loadedBuild = cfg.build;
|
const loadedBuild = cfg.build;
|
||||||
|
|||||||
+26
-11
@@ -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 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 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 lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
|
||||||
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
|
const 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
|
// 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);
|
return lyrForRadio(t, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lyrOpen() {
|
// fs: the full-screen Now Playing view (art on the left, the words on the right, the player along the bottom)
|
||||||
const t = cur(); if (!t || lyr.open) return;
|
function lyrOpen(fs) {
|
||||||
lyr.open = true; document.body.classList.add('lyropen');
|
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');
|
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 });
|
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
|
||||||
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
||||||
}
|
}
|
||||||
function lyrClose() {
|
function lyrClose() {
|
||||||
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
|
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 lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
|
||||||
|
const lyrCls = () => (lyrPref().karaoke && !lyrSpokenKey(cur()) ? 'kar' : '') + (lyr.fs ? ' fs' : '');
|
||||||
function lyrShell() {
|
function lyrShell() {
|
||||||
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
|
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.style.setProperty('--lf', pref.size + 'px');
|
||||||
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
|
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>
|
<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="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="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>
|
<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="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>
|
<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>
|
<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);
|
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)
|
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('#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'); };
|
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('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
|
||||||
$l('#lyrs').onclick = lyrSearchAgain; $l('#lyrsh').onclick = lyrShare;
|
$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'; };
|
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()));
|
$l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
|
||||||
syncUI();
|
modeUI(); syncUI();
|
||||||
}
|
}
|
||||||
async function lyrReload(force) {
|
async function lyrReload(force) {
|
||||||
const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2;
|
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>'}`;
|
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;
|
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 +' : '');
|
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.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.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;
|
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() {
|
function lyrSearchAgain() {
|
||||||
const t = cur(); if (!t) return; const q = lyrQuery(t);
|
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">
|
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">
|
||||||
|
|||||||
+11
-4
@@ -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
|
// ---- 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>` : '';
|
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>` : ''}
|
${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="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>
|
<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
|
// ================================================================ 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) {
|
async function musicFamilyPage({ v }, id) {
|
||||||
mmLoading(v, 'Family playlist');
|
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; }
|
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 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);
|
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); });
|
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 ($('#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); } });
|
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); } });
|
||||||
|
|||||||
+17
-4
@@ -115,13 +115,17 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
|||||||
px.serverKey = st.server;
|
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 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 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())}">
|
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) => {
|
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">'}
|
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>`;
|
<div class="${items === 'raw' ? '' : 'agrid'}" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
|
||||||
plexHomeBadge(st);
|
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');
|
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',
|
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(); } }))); };
|
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>
|
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>
|
<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>` : ''}
|
${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>
|
<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>
|
<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>`;
|
<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); } } }] : []); });
|
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) {
|
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);
|
$('#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); } });
|
$('#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 });
|
||||||
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);
|
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
|
||||||
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
||||||
markPlaying();
|
markPlaying();
|
||||||
|
|||||||
+90
-35
@@ -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.
|
// 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 sleep = ms => new Promise(ok => setTimeout(ok, ms));
|
||||||
const tc = { m: new Map(), ctl: null, busy: false };
|
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() {
|
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); }
|
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
|
||||||
}
|
}
|
||||||
async function tcFetch(src, signal) {
|
async function tcFetch(src, signal) {
|
||||||
@@ -32,7 +35,7 @@ async function tcFetch(src, signal) {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
|
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
|
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;
|
return;
|
||||||
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
|
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,8 @@ async function tcPump() {
|
|||||||
try {
|
try {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
tcTrim();
|
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;
|
tc.ctl = new AbortController(); tc.cur = src;
|
||||||
await tcFetch(src, tc.ctl.signal);
|
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
|
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(); }
|
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
|
||||||
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
|
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;
|
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;
|
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.
|
// 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) {
|
function loadCur(delay = 220) {
|
||||||
const t = cur(); if (!t) return;
|
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;
|
const tok = ++loadTok; pl.buf = true;
|
||||||
if (delay) pl.a.pause();
|
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();
|
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
|
||||||
if (lyr.open) { lyrShell(); lyrReload(); }
|
if (lyr.open) { lyrShell(); lyrReload(); }
|
||||||
if (typeof vizTrackChanged === 'function') vizTrackChanged();
|
if (typeof vizTrackChanged === 'function') vizTrackChanged();
|
||||||
if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint();
|
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 = () => {
|
const go = () => {
|
||||||
if (tok !== loadTok) return;
|
if (tok !== loadTok) return;
|
||||||
const hit = tc.m.get(t.src);
|
const hit = tc.m.get(t.src);
|
||||||
pl.a.src = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
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 || ''));
|
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();
|
tcKick();
|
||||||
const n = pl.q[pl.i + 1]; if (n) new Image().src = px.art(n.thumb, 600);
|
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) {
|
function setMeta(t) {
|
||||||
if (!('mediaSession' in navigator)) return;
|
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.
|
// 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) {
|
function recover(now) {
|
||||||
clearTimeout(stallTimer); const t = cur(), a = pl.a;
|
clearTimeout(stallTimer); const t = cur(), a = pl.a;
|
||||||
if (!t || a.paused) return;
|
if (!t || (a.paused && !pl.recovering)) return;
|
||||||
stallTimer = setTimeout(() => {
|
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 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)
|
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
|
||||||
if (bad && !hit) {
|
if (bad && !hit) {
|
||||||
@@ -93,12 +115,14 @@ function recover(now) {
|
|||||||
}
|
}
|
||||||
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
||||||
if (!hit && !navigator.onLine) return; // 'online' will call us again
|
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++;
|
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 });
|
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
|
||||||
recover();
|
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 }); }
|
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'); };
|
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 });
|
pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
|
||||||
renderMini(); syncUI();
|
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() {
|
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 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 }) => `
|
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>
|
<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 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 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';
|
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).
|
// 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;
|
let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
|
||||||
@@ -162,7 +207,10 @@ function tlReport(state) {
|
|||||||
tlSend(state, tlKey, tlPos, tlDur);
|
tlSend(state, tlKey, tlPos, tlDur);
|
||||||
}
|
}
|
||||||
function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
|
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;
|
let lastSave = 0, lastPos = 0;
|
||||||
pl.a.addEventListener('timeupdate', () => {
|
pl.a.addEventListener('timeupdate', () => {
|
||||||
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
|
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
|
||||||
@@ -174,14 +222,14 @@ pl.a.addEventListener('timeupdate', () => {
|
|||||||
});
|
});
|
||||||
addEventListener('pagehide', saveState);
|
addEventListener('pagehide', saveState);
|
||||||
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
|
['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('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.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
|
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());
|
pl.a.addEventListener('canplaythrough', () => tcKick());
|
||||||
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
|
['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) {
|
if ('mediaSession' in navigator) {
|
||||||
const ms = navigator.mediaSession;
|
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);
|
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
|
||||||
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
|
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 {}
|
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() {
|
function renderMini() {
|
||||||
if (typeof videoBarOn === 'function' && videoBarOn()) return; // the bottom bar is showing the video's controls
|
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);
|
const t = cur(), el = $('#player'); if (!t) { el.hidden = true; return qDock(); }
|
||||||
el.hidden = false;
|
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)}">
|
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="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" 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>`;
|
<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(); };
|
$('#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');
|
$('#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
|
// 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; };
|
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; }; };
|
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.
|
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
|
||||||
function modeUI() {
|
function modeUI() {
|
||||||
document.querySelectorAll('#ms, #nps, #cns').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
|
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').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('#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() {
|
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;
|
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 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 cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
|
||||||
const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 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; }
|
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() {
|
function renderNpCard() {
|
||||||
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music');
|
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home');
|
||||||
const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null;
|
const host = onHome ? $('#npslot') : null;
|
||||||
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
|
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
|
||||||
const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
|
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>
|
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-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>
|
<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.
|
// The Now Playing sheet stays open while tracks change, so refresh what it shows.
|
||||||
function updateNow() {
|
function updateNow() {
|
||||||
|
qDock();
|
||||||
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
|
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
|
||||||
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
|
$('#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;
|
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
|
||||||
modeUI();
|
modeUI();
|
||||||
}
|
}
|
||||||
function openNow() {
|
function openNow() {
|
||||||
const t = cur(); if (!t) return;
|
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>
|
const chip = (id, ic, label) => `<button class="npchip" id="${id}">${icon(ic)}<span>${label}</span></button>`;
|
||||||
<p class="sub" id="npsub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
|
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>
|
||||||
<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>
|
<p class="sub" id="npsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p></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>
|
<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>
|
||||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></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>
|
||||||
<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>`);
|
<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');
|
const sk = el.querySelector('#npk');
|
||||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
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; };
|
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('#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('#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('#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');
|
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||||
|
|||||||
@@ -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-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-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>
|
<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>' : ''}
|
${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>
|
</div>
|
||||||
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</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';
|
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(); }; }
|
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'; });
|
}).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'); }; }
|
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) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); $('#stbtn').onclick = () => screenTimeSheet(); }
|
||||||
if (isAdmin) jfetch('/api/health').then(h => {
|
if (isAdmin) jfetch('/api/health').then(h => {
|
||||||
|
|||||||
+46
-2
@@ -29,19 +29,27 @@ async function viewStore(args = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- My Store
|
// ---------------------------------------------------------------- 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 stLoad() { const r = await stApi('/items'); stItems = r.items; stInstalled = r.installed; stSeen = r.seen || {}; stAdmin = r.admin; return r; }
|
||||||
async function stMine(el) {
|
async function stMine(el) {
|
||||||
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
try { await stLoad(); } catch (e) { el.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
if (!$('#stbody')) return;
|
if (!$('#stbody')) return;
|
||||||
const mine = stItems.filter(i => i.kind === 'extension' && stInstalled.includes(i.id)), shown = stItems.filter(i => stFilter === 'all' || i.kind === stFilter);
|
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>` : ''}
|
${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>
|
<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>`}`;
|
${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('#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 ($('#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();
|
draw();
|
||||||
}
|
}
|
||||||
@@ -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>`; };
|
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>`;
|
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'; }
|
||||||
|
};
|
||||||
|
}
|
||||||
+93
-5
@@ -146,8 +146,8 @@ body.editing #view { padding: 0; display: flex; flex-direction: column; }
|
|||||||
|
|
||||||
/* Music */
|
/* Music */
|
||||||
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
|
#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 { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
|
||||||
.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 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; }
|
.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)); }
|
.agrid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||||
.acard { display: block; min-width: 0; }
|
.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); } }
|
.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 { 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; }
|
#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 { position: fixed; inset: 0 0 var(--playerh, 0px) 0; z-index: 90; border-radius: 0; }
|
||||||
.vstage.vfull video, .vstage:fullscreen video { height: 100dvh; max-height: none; object-fit: contain; }
|
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 { border-radius: 0; background: #000; width: 100%; height: 100%; }
|
||||||
.vstage:fullscreen video { 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; } }
|
@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; }
|
.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 */
|
/* 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 .vright { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
||||||
.vtop .vtitle { flex: 0 1 auto; max-width: 38%; }
|
.vtop .vtitle { flex: 0 1 auto; max-width: 38%; }
|
||||||
.vtop .vtc { flex: 0 1 auto; }
|
.vtop .vtc { flex: 0 1 auto; }
|
||||||
@@ -1112,3 +1112,91 @@ 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; }
|
.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; }
|
.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%; }
|
.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; }
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v134';
|
const CACHE = 'hub-v138';
|
||||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
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 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'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|||||||
+28
-33
@@ -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>`;
|
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">
|
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="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="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>`;
|
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
|
||||||
const stage = $('#vstage'); stage.prepend(el); el.controls = false;
|
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;
|
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.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'); };
|
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;
|
let lastTap = 0, lastDbl = 0;
|
||||||
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
|
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
|
||||||
stage.addEventListener('pointerup', e => {
|
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') {
|
if (e.pointerType === 'touch') {
|
||||||
const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width;
|
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; }
|
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(); }
|
} 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);
|
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'">
|
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>
|
<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>` : ''}
|
${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>`;
|
<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);
|
const q = s => el.querySelector(s);
|
||||||
q('#vbpp').onclick = () => { const v = vp && vp.el; if (v) v.paused ? v.play().catch(() => {}) : v.pause(); };
|
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);
|
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);
|
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('#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; };
|
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)
|
// 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,
|
/** 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. */
|
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
|
||||||
function videoFullscreen() {
|
function videoFullscreen() {
|
||||||
if (!vp) return; const box = document.querySelector('.vstage'), el = vp.el; if (!box) return;
|
if (!vp) return; const box = document.querySelector('.vstage'); if (!box) return;
|
||||||
const native = document.fullscreenElement || document.webkitFullscreenElement;
|
if (box.classList.contains('vfull')) return videoFsExit();
|
||||||
if (native) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); return; }
|
box.classList.add('vfull'); document.body.classList.add('vfullwin'); // (the picture fills the window; the bottom bar stays under it with the controls)
|
||||||
if (box.classList.contains('vfull')) { box.classList.remove('vfull'); document.body.classList.remove('vfullwin'); return; }
|
const root = document.documentElement, req = root.requestFullscreen || root.webkitRequestFullscreen;
|
||||||
const req = box.requestFullscreen || box.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)
|
||||||
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(); }
|
|
||||||
}
|
}
|
||||||
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. */
|
/** 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) {
|
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;
|
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 === '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 === '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 === '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 (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)); }
|
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
|
||||||
}, true);
|
}, true);
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ const users = require('./users');
|
|||||||
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
|
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.)
|
// 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 SECRET = new Set(['ab_creds', 'sf_token']);
|
||||||
const MAX_VALUE = 20000; // bytes of JSON per key
|
const MAX_VALUE = 20000; // bytes of JSON per key
|
||||||
|
|
||||||
|
|||||||
@@ -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(`${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);
|
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);
|
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é & 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
|
// music: several files, streamed with ranges
|
||||||
let alb = await (await post('/items', { kind: 'music', title: 'Album', creator: 'Band' })).json(); const mp3 = Buffer.from('0123456789abcdefghij');
|
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);
|
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);
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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');
|
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');
|
const root = __dirname + '/../public/', rd = f => fs.readFileSync(root + f, 'utf8');
|
||||||
ok(rd('index.html').includes('/visualizer.js'), 'script is loaded');
|
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');
|
ok(rd('player.js').includes('vizTrackChanged') && rd('radio-view.js').includes('vizTrackChanged'), 'follows track changes');
|
||||||
console.log('visualizer: ' + n + ' checks passed');
|
console.log('visualizer: ' + n + ' checks passed');
|
||||||
Reference in new issue
Block a user