diff --git a/public/audiobooks-view.js b/public/audiobooks-view.js index 676f77a..b96dbed 100644 --- a/public/audiobooks-view.js +++ b/public/audiobooks-view.js @@ -7,6 +7,11 @@ const abId = b => String(abPick(b, 'id', 'asin', 'ASIN', 'book_id')); const abTitle = b => abPick(b, 'title', 'title_short', 'name') || '(untitled)'; const abAuthor = b => { const a = abPick(b, 'author', 'author_name', 'authors', 'author_display') || ''; return Array.isArray(a) ? a.join(', ') : a; }; const abSeries = b => abPick(b, 'series_name', 'series') || ''; +const abNarr = b => { const a = abPick(b, 'narrated_by', 'narrator', 'narrators', 'narrator_name') || ''; return Array.isArray(a) ? a.join(', ') : a; }; +const abSecs = b => { const d = Number(abPick(b, 'seconds', 'duration', 'duration_sec', 'length_sec', 'runtime_seconds')); return d > 0 ? (d > 1e5 ? d / 1000 : d) : 0; }; // (some servers give milliseconds) +/** When the book was added to the library (ms), from whichever date the server gives; 0 when it gives none. */ +const abAdded = b => { for (const k of ['added', 'added_at', 'date_added', 'created_at', 'created', 'purchase_date', 'mtime']) { const v = b && b[k]; if (v == null || v === '') continue; const t = typeof v === 'number' ? (v < 1e11 ? v * 1000 : v) : Date.parse(v); if (t > 0) return t; } return 0; }; +const abLen = s => { const h = Math.floor(s / 3600), m = Math.round(s % 3600 / 60); return h ? `${h} h${m ? ' ' + m + ' m' : ''}` : `${m} m`; }; const abCover = b => `/api/ab/books/${encodeURIComponent(abId(b))}/cover`; const abFmt = s => { s = Math.max(0, Math.floor(s || 0)); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); }; const abHue = t => { let h = 0; for (const c of t) h = (h * 31 + c.charCodeAt(0)) % 360; return h; }; @@ -74,21 +79,38 @@ async function viewBooks(args = []) { if (!location.hash.startsWith('#/audio')) return; const pct = b => { const p = abProg[abId(b)]; return p && p.dur ? Math.min(100, p.pos / p.dur * 100) : 0; }; const card = (b, wide) => ``; + ${pct(b) > 1 ? `` : ''}${isNew(b) ? 'New' : ''} + ${esc(abTitle(b))}${esc(abAuthor(b))}${!wide && abSecs(b) ? `${esc(abLen(abSecs(b)))}${abSeries(b) ? ' · ' + esc(abSeries(b)) : ''}` : ''}`; + const WEEKS = 3 * 7 * 864e5, isNew = b => { const t = abAdded(b); return t && Date.now() - t < WEEKS && pct(b) < 1; }; + const dated = abBooks.some(abAdded), newest = [...abBooks].sort(dated ? (a, b) => abAdded(b) - abAdded(a) : () => 0).slice(0, 12); // (no dates: the server's own order, newest last) + if (!dated) newest.splice(0, newest.length, ...abBooks.slice(-12).reverse()); + const stat = b => { const p = abProg[abId(b)]; return !p || !p.dur || p.pos <= 10 ? 'new' : p.pos >= p.dur - 30 ? 'done' : 'going'; }; + const view = (() => { try { return JSON.parse(localStorage.getItem('ab_view')) || {}; } catch { return {}; } })(); + let sort = view.sort || 'title', filt = view.filt || 'all'; const cont = abBooks.filter(b => { const p = abProg[abId(b)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; }).sort((a, b) => abProg[abId(b)].t - abProg[abId(a)].t).slice(0, 10); const contFree = lvShelf.filter(b => lvLeft(b.id)).sort((a, b) => lvProg['lv:' + b.id].t - lvProg['lv:' + a.id].t).slice(0, 10); v.innerHTML = `

Audiobooks

Free audiobooksClassic player
${cont.length || contFree.length ? `

Continue listening

${[...cont.map(b => ({ t: abProg[abId(b)].t, h: card(b, true) })), ...contFree.map(b => ({ t: lvProg['lv:' + b.id].t, h: lvCard(b, true) }))].sort((a, b) => b.t - a.t).map(x => x.h).join('')}
` : ''} + ${newest.length > 1 ? `

Newly added

${newest.map(b => card(b, true)).join('')}
` : ''} ${lvShelf.length ? `

My free audiobooks

${lvRecent().map(b => lvCard(b)).join('')}
` : ''} -

Library

`; +

Library

+
${[['all', 'All'], ['going', 'In progress'], ['new', 'Not started'], ['done', 'Finished']].map(([k, l]) => ``).join('')} +
+
`; const draw = () => { - const q = $('#abq').value.trim().toLowerCase(), list = abBooks.filter(b => !q || (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(q)) - .sort((a, b) => abTitle(a).localeCompare(abTitle(b))); + const q = $('#abq').value.trim().toLowerCase(), by = { title: (a, b) => abTitle(a).localeCompare(abTitle(b)), author: (a, b) => abAuthor(a).localeCompare(abAuthor(b)) || abTitle(a).localeCompare(abTitle(b)), + added: (a, b) => abAdded(b) - abAdded(a) || abTitle(a).localeCompare(abTitle(b)), length: (a, b) => abSecs(b) - abSecs(a), + series: (a, b) => (abSeries(a) || '\uffff').localeCompare(abSeries(b) || '\uffff') || (Number(abPick(a, 'series_sequence', 'series_index')) || 0) - (Number(abPick(b, 'series_sequence', 'series_index')) || 0) || abTitle(a).localeCompare(abTitle(b)) }[sort], + list = abBooks.filter(b => (filt === 'all' || stat(b) === filt) && (!q || (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b) + ' ' + abNarr(b)).toLowerCase().includes(q))).sort(by); $('#abgrid').innerHTML = list.map(b => card(b)).join('') || `

${abBooks.length ? 'No books match.' : 'No books in the library yet.'}

`; + $('#abn').textContent = list.length === abBooks.length ? `${abBooks.length} books` : `${list.length} of ${abBooks.length}`; + v.querySelectorAll('.abf').forEach(x => x.classList.toggle('on', x.dataset.f === filt)); + $('#abs0').value = sort; + try { localStorage.setItem('ab_view', JSON.stringify({ sort, filt })); } catch {} }; - draw(); $('#abq').oninput = draw; + draw(); $('#abq').oninput = draw; $('#abs0').onchange = e => { sort = e.target.value; draw(); }; + v.querySelectorAll('.abf').forEach(x => x.onclick = () => { filt = x.dataset.f; draw(); }); const byId = new Map(abBooks.map(b => [abId(b), b])); v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); }; lvBind(v, () => route()); @@ -98,7 +120,7 @@ async function viewBooks(args = []) { function abSheet(b) { const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30; const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `
-

${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}

+
${abNarr(b) ? `

Read by ${esc(abNarr(b))}

` : ''}${abSecs(b) ? `

${esc(abLen(abSecs(b)))}${abAdded(b) ? ' · added ' + new Date(abAdded(b)).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : ''}

` : ''}

${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}

${resumable ? '' : ''}${allowed('chat') ? `` : ''}
`); const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } }; el.querySelector('#abp').onclick = () => go(); diff --git a/public/style.css b/public/style.css index c9f09a0..dfcb5d2 100644 --- a/public/style.css +++ b/public/style.css @@ -1215,3 +1215,5 @@ body.qdock #view { padding-right: 340px; } @keyframes askpulse { 50% { box-shadow: 0 4px 22px rgba(217,69,58,.85); } } @media (prefers-reduced-motion: reduce) { #askpill { animation: none; } } body:has(#sheet) #askpill { display: none; } +.abnew { position: absolute; top: 6px; left: 6px; font-style: normal; font-size: 11px; font-weight: 700; padding: 2px 7px; border-radius: 99px; background: var(--accent); color: #fff; } +.abf.on { background: var(--accent); color: #fff; border-color: var(--accent); } diff --git a/public/sw.js b/public/sw.js index 6bbe0cb..deec3aa 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v144'; +const CACHE = 'hub-v145'; const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];