Podcast: resume where you left off, synced between devices (shared progress store with audiobooks)
build-container / build (push) Successful in 9m26s
build-container / build (push) Successful in 9m26s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa2133ed9a
commit
b3494b9720
5 files changed
+61
-16
No files matched your search
+28
-3
@@ -12,6 +12,26 @@ const pdTime = d => Math.max(0, Date.parse(d) || 0);
|
||||
const pdPlain = h => { const d = document.createElement('div'); d.innerHTML = String(h || ''); return (d.textContent || '').trim(); };
|
||||
const pdTrack = (e, show) => ({ key: 'pod:' + pdId(e), title: pdF(e, 'title') || 'Episode', artist: pdF(show, 'title') || '', album: '', thumb: pdImg('podcasts', show), src: '/api/podcast/podcastitems/' + encodeURIComponent(pdId(e)) + '/file', nocache: true, dur: (+pdF(e, 'duration') || 0) * 1000 });
|
||||
|
||||
// Where you got to in each episode is kept on the server (per person), so it follows you between devices.
|
||||
let pdProg = {}, pdLast = 0, pdQueued = {};
|
||||
const pdPlaying = () => { const t = cur(); return t && String(t.key).startsWith('pod:') ? t.key.slice(4) : ''; };
|
||||
const pdLoadProg = async () => { try { for (const [id, p] of Object.entries(await pdApi('/progress') || {})) if (!pdProg[id] || p.t > pdProg[id].t) pdProg[id] = p; } catch {} };
|
||||
const pdPush = keepalive => {
|
||||
if (!Object.keys(pdQueued).length) return;
|
||||
const body = JSON.stringify(pdQueued); pdQueued = {};
|
||||
fetch('/api/podcast/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive })
|
||||
.then(r => { if (!r.ok) throw 0; }).catch(() => { pdQueued = { ...JSON.parse(body), ...pdQueued }; });
|
||||
};
|
||||
const pdSave = keepalive => {
|
||||
const id = pdPlaying(), d = pl.a.duration; if (!id || !isFinite(d) || d <= 0) return;
|
||||
pdProg[id] = pdQueued[id] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; pdPush(keepalive);
|
||||
};
|
||||
pl.a.addEventListener('timeupdate', () => { if (Date.now() - pdLast > 8000) { pdLast = Date.now(); pdSave(); } });
|
||||
pl.a.addEventListener('pause', () => pdSave());
|
||||
pl.a.addEventListener('ended', () => pdSave());
|
||||
addEventListener('pagehide', () => pdSave(true));
|
||||
const pdLeft = e => { const p = pdProg[pdId(e)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; }; // started but not finished
|
||||
|
||||
async function viewPodcast(args = []) {
|
||||
const v = $('#view');
|
||||
v.innerHTML = '<div class="wrap"><h1>Podcast</h1><p class="empty">Loading…</p></div>';
|
||||
@@ -59,11 +79,11 @@ async function pdHome() {
|
||||
}
|
||||
|
||||
async function pdShow(id) {
|
||||
const [show, items0] = await Promise.all([pdApi('/podcasts/' + encodeURIComponent(id)), pdApi('/podcasts/' + encodeURIComponent(id) + '/items')]);
|
||||
const [, show, items0] = await Promise.all([pdLoadProg(), pdApi('/podcasts/' + encodeURIComponent(id)), pdApi('/podcasts/' + encodeURIComponent(id) + '/items')]);
|
||||
if (!location.hash.startsWith('#/podcast')) return;
|
||||
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' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||
<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>`;
|
||||
$('#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>
|
||||
@@ -72,7 +92,12 @@ async function pdShow(id) {
|
||||
<div class="bar">${items.length ? `<button class="btn" id="pdlatest">${icon('play')}Play latest</button>` : ''}<button class="btn ghost" id="pdmore">${icon('more')}More</button></div>
|
||||
<h2>Episodes</h2><div class="list">${items.map(ep).join('') || '<p class="empty">No episodes yet. Podgrab may still be fetching the feed.</p>'}</div></div>`;
|
||||
const byId = new Map(items.map(e => [String(pdId(e)), e]));
|
||||
const playFrom = e => { const i = items.indexOf(e); playQueue(items.slice(0, i + 1).reverse().map(x => pdTrack(x, show)), 0); }; // this one, then the newer ones after it
|
||||
const playFrom = e => { // this one (from where you left off), then the newer ones after it
|
||||
const i = items.indexOf(e), id = String(pdId(e)), p = pdLeft(e);
|
||||
if (pdPlaying() === id) { if (pl.a.paused) pl.a.play().catch(() => {}); return; }
|
||||
playQueue(items.slice(0, i + 1).reverse().map(x => pdTrack(x, show)), 0);
|
||||
if (p) pl.a.addEventListener('loadedmetadata', () => { if (pdPlaying() === id) pl.a.currentTime = p.pos; }, { once: true });
|
||||
};
|
||||
document.querySelectorAll('.pdep').forEach(r => {
|
||||
const e = byId.get(r.dataset.id);
|
||||
r.querySelector('[data-play]').onclick = () => playFrom(e);
|
||||
|
||||
Reference in new issue
Block a user