Merge suggestions

This commit is contained in:
Claude committed 2026-10-26 12:16:47 +00:00
commit ce76f4fa8d
10 files changed
+47 -4

No files matched your search

+1 -1
View File
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']);
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups']);
const outbox = new Map(); let outTimer = null, warned = false;
function flushSettings() {
outTimer = null;
+1
View File
@@ -70,6 +70,7 @@
<script src="/video.js"></script>
<script src="/office.js"></script>
<script src="/player.js"></script>
<script src="/offline.js"></script>
<script src="/podcast-view.js"></script>
<script src="/reader.js"></script>
<script src="/audiobooks-view.js"></script>
+1 -1
View File
@@ -68,7 +68,7 @@ async function lyrLoad(t, force) {
if (!force && lyr.cache.has(id)) return lyrForRadio(t, lyr.cache.get(id));
if (lyrIsRadio(t) && !t.song) return { found: false, error: 'This station does not say which song is playing, so there is nothing to look up.' };
let res = force || lyrIsRadio(t) ? null : await lyrFromPlex(t);
if (!res && cfg.lyrics) {
if (!res && cfg.lyrics && store.get('lookups', true) !== false) {
const q = lyrQuery(t), u = '/api/lyrics?' + new URLSearchParams({ artist: q.artist, title: lyrCleanTitle(q.title), duration: (t.dur || 0) / 1000 || '' });
try { res = await jfetch(u); if (!res.found && lyrCleanTitle(q.title) !== q.title) { /* the cleaned title is what we asked; nothing else to try */ } } catch (e) { res = { found: false, error: e.message }; }
}
+16
View File
@@ -0,0 +1,16 @@
'use strict';
// Save podcast episodes on this device, so they play with no signal. They live in the browser's own storage (a cache the service worker answers from),
// so they are not synced between devices, and the browser may clear them if the phone runs low on space.
const OFFLINE = 'hub-offline';
const offUrl = id => '/api/podcast/podcasts/' + encodeURIComponent(id) + '/file';
const offHas = async id => { try { return !!(await (await caches.open(OFFLINE)).match(offUrl(id))); } catch { return false; } };
async function offSave(id, onDone) {
if (!('caches' in window)) { toast('This browser cannot save for offline'); return; }
toast('Saving…');
try {
const r = await fetch(offUrl(id), { credentials: 'same-origin' }); if (!r.ok) throw new Error('Could not download (' + r.status + ')');
await (await caches.open(OFFLINE)).put(offUrl(id), r); toast('Saved on this device'); if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {});
} catch (e) { try { await (await caches.open(OFFLINE)).delete(offUrl(id)); } catch {} toast(e.message || 'Could not save'); }
if (onDone) onDone();
}
async function offRemove(id, onDone) { try { await (await caches.open(OFFLINE)).delete(offUrl(id)); toast('Removed from this device'); } catch {} if (onDone) onDone(); }
+3 -1
View File
@@ -84,7 +84,7 @@ async function pdShow(id) {
const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate')));
const paused = !!pdF(show, 'isPaused'), ep = e => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : pdLeft(e) ? pdDur(pdLeft(e).dur - pdLeft(e).pos) + ' left' : ''].filter(Boolean).join(' · '))}</small></span>
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button></div>`;
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button><button class="ibtn" data-off aria-label="Save for offline" title="Save for offline">${icon('dl')}</button></div>`;
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Back</a></div>
<div style="display:flex;gap:16px;align-items:flex-start"><span class="vimg" style="width:120px;flex:none;aspect-ratio:1/1"><img alt="" src="${pdImg('podcasts', show)}" onerror="this.remove()"></span>
<div style="min-width:0"><h1 style="margin:0">${esc(pdF(show, 'title'))}</h1><p class="sub" style="margin:2px 0 0">${esc(pdF(show, 'author') || '')}</p>
@@ -101,6 +101,8 @@ async function pdShow(id) {
document.querySelectorAll('.pdep').forEach(r => {
const e = byId.get(r.dataset.id);
r.querySelector('[data-play]').onclick = () => playFrom(e);
const ob = r.querySelector('[data-off]'), paint = async () => { const on = await offHas(pdId(e)); ob.style.color = on ? 'var(--accent)' : ''; ob.title = ob.ariaLabel = on ? 'Saved on this device (tap to remove)' : 'Save for offline'; return on; };
paint(); ob.onclick = async () => { const id = pdId(e); if (await offHas(id)) offRemove(id, paint); else offSave(id, paint); };
r.querySelector('[data-seen]').onclick = async () => {
try { await pdApi('/podcastitems/' + encodeURIComponent(pdId(e)) + (pdF(e, 'isPlayed') ? '/markUnplayed' : '/markPlayed')); route(); } catch (err) { toast(err.message); }
};
+1 -1
View File
@@ -3,7 +3,7 @@
// out of it), so stations that only offer plain http still play, and everything plays in the same player as Music.
const rdoApi = (p, opts) => jfetch('/api/radio' + p, opts);
const rdoOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
const rdoArt = (s, song) => '/api/radio/art?s=' + encodeURIComponent(s.id) + (song && song.title ? '&artist=' + encodeURIComponent(song.artist || '') + '&title=' + encodeURIComponent(song.title) : '');
const rdoArt = (s, song) => '/api/radio/art?s=' + encodeURIComponent(s.id) + (song && song.title && store.get('lookups', true) !== false ? '&artist=' + encodeURIComponent(song.artist || '') + '&title=' + encodeURIComponent(song.title) : '');
const rdoIcon = s => s.favicon ? '/api/radio/icon?u=' + encodeURIComponent(s.favicon) : '';
const rdoTrack = s => ({ key: 'radio:' + s.id, title: s.name, artist: 'Live radio', album: [s.state, s.country].filter(Boolean).join(', '), album0: [s.state, s.country].filter(Boolean).join(', '), thumb: rdoArt(s), src: '/api/radio/play/' + encodeURIComponent(s.id), nocache: true, dur: 0, station: s.name });
const rdoPlaying = () => { const t = cur(); return t && String(t.key).startsWith('radio:') ? t : null; };
+2
View File
@@ -77,6 +77,7 @@ function viewSettings() {
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
${allowed('music') || allowed('radio') ? '<label class="row" style="cursor:pointer"><span class="grow"><b>Look up lyrics and song covers online</b><small>Sends the artist and title of what is playing to LRCLIB and iTunes. Turn off to keep that private.</small></span><input type="checkbox" id="lookups"></label>' : ''}
</div>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
<div id="sec-mail"><h2>Mail</h2><div class="list" id="mailst"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>
@@ -128,6 +129,7 @@ function viewSettings() {
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server + (st.user ? ' · as ' + st.user.title : '') : 'Signed in · no server chosen') : 'Signed out';
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
const 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(); }
if (isAdmin) jfetch('/api/health').then(h => {
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${bad ? '⚠ ' : '✓ '}${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
+11
View File
@@ -22,6 +22,16 @@ self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
self.clients.claim();
});
// Podcast episodes saved on this device (see offline.js): answer from them, including the byte ranges an audio player asks for; otherwise use the network as normal.
async function offlineAudio(req) {
const hit = await (await caches.open('hub-offline')).match(req.url);
if (!hit) return fetch(req);
const blob = await hit.blob(), size = blob.size, type = hit.headers.get('content-type') || 'audio/mpeg', m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get('range') || '');
if (!m) return new Response(blob, { status: 200, headers: { 'content-type': type, 'content-length': size, 'accept-ranges': 'bytes' } });
let a = m[1] === '' ? Math.max(0, size - +m[2]) : +m[1], b = m[1] === '' || m[2] === '' ? size - 1 : Math.min(+m[2], size - 1);
if (a > b || a >= size) return new Response(null, { status: 416, headers: { 'content-range': 'bytes */' + size } });
return new Response(blob.slice(a, b + 1), { status: 206, headers: { 'content-type': type, 'content-length': b - a + 1, 'content-range': `bytes ${a}-${b}/${size}`, 'accept-ranges': 'bytes' } });
}
// Share target: other apps send files here (POST); keep them and open the app, which asks where to put them.
async function takeShare(request) {
try {
@@ -35,6 +45,7 @@ async function takeShare(request) {
self.addEventListener('fetch', e => {
const u = new URL(e.request.url);
if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/podcast\/podcasts\/[^/]+\/file$/.test(u.pathname)) return e.respondWith(offlineAudio(e.request));
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
e.respondWith(fetch(e.request).then(r => {
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
+5
View File
@@ -16,6 +16,11 @@ async function homeResume(el) {
return cont.length ? `<h3 class="sub" style="margin:10px 0 4px">Continue listening</h3><div class="vrow">${cont.map(b => { const p = abProg[abId(b)], pc = Math.min(100, p.pos / p.dur * 100);
return `<a class="vcard" href="#/audio" data-book="${esc(abId(b))}" style="flex:none;width:130px"><span class="vimg" style="aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(abTitle(b))} 55% 40%),hsl(${abHue(abTitle(b)) + 40} 55% 28%))"><img loading="lazy" alt="" src="${abCover(b)}" onerror="this.remove()">${pc > 1 ? `<i class="vprog"><b style="width:${pc}%"></b></i>` : ''}</span><b>${esc(abTitle(b))}</b><small>${esc(abAuthor(b))}</small></a>`; }).join('')}</div>` : '';
})() : '',
allowed('read') ? (async () => {
const list = await jfetch('/api/read/list');
const cont = list.filter(b => b.pct > 0.01 && b.pct < 0.98).sort((a, b) => b.read - a.read).slice(0, 6);
return cont.length ? `<h3 class="sub" style="margin:10px 0 4px">Continue reading</h3><div class="vrow">${cont.map(b => `<a class="vcard" href="#/read/open/${esc(b.id)}" style="flex:none;width:110px"><span class="vimg rdcover" style="aspect-ratio:2/3">${b.cover ? `<img loading="lazy" alt="" src="/api/read/book/${esc(b.id)}/cover" onerror="this.remove()">` : ''}<em>${esc(b.title)}</em><u class="rdprog"><s style="width:${Math.round(b.pct * 100)}%"></s></u></span><b>${esc(b.title)}</b><small>${Math.round(b.pct * 100)}%</small></a>`).join('')}</div>` : '';
})() : '',
]);
const html = bits.map(b => b.status === 'fulfilled' ? b.value : '').join('');
if (!html || !el.isConnected) return;