Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
120 lines
12 KiB
JavaScript
120 lines
12 KiB
JavaScript
'use strict';
|
|
// ---- Podcast: your shows and episodes from Podgrab, through the hub (the server holds the Podgrab sign-in). Episodes play in the same player as Music.
|
|
const pdApi = (p, opts) => jfetch('/api/podcast' + p, opts);
|
|
const pdOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
|
|
// Podgrab sends its field names with a capital letter (ID, Title, ...); accept either spelling.
|
|
const pdF = (o, k) => o == null ? undefined : o[k] !== undefined ? o[k] : o[k[0].toUpperCase() + k.slice(1)] !== undefined ? o[k[0].toUpperCase() + k.slice(1)] : o[k.toUpperCase()];
|
|
const pdId = o => pdF(o, 'id');
|
|
const pdImg = (kind, o, w) => `/api/podcast/${kind}/${encodeURIComponent(pdId(o))}/image`;
|
|
const pdDur = s => { s = Math.round(+s || 0); if (!s) return ''; const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : Math.max(1, m) + ' min'; };
|
|
const pdDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; };
|
|
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>';
|
|
try {
|
|
if (args[0] === 'show' && args[1]) return await pdShow(args[1]);
|
|
return await pdHome();
|
|
} catch (e) {
|
|
if (!location.hash.startsWith('#/podcast')) return;
|
|
v.innerHTML = `<div class="wrap"><h1>Podcast</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="pdretry">Try again</button><a class="btn ghost" href="#/podcast/vbv">Verse By Verse</a>${cfg.podcast ? `<a class="btn ghost" href="${esc(cfg.podcast)}" target="_blank" rel="noopener">Open Podgrab</a>` : ''}</div></div>`;
|
|
$('#pdretry').onclick = () => route();
|
|
}
|
|
}
|
|
|
|
async function pdHome() {
|
|
const list = await pdApi('/podcasts?sort=lastepisode&order=desc'), shows = Array.isArray(list) ? list : (list.podcasts || []);
|
|
if (!location.hash.startsWith('#/podcast')) return;
|
|
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Podcast</h1><span class="pspace grow"></span><a class="btn ghost" href="#/podcast/vbv">Verse By Verse</a><button class="btn" id="pdadd">${icon('plus')}Add podcast</button></div>
|
|
${shows.length ? `<div class="vgrid">${shows.map(s => `<a class="vcard" href="#/podcast/show/${encodeURIComponent(pdId(s))}">
|
|
<span class="vimg" style="aspect-ratio:1/1"><img loading="lazy" alt="" src="${pdImg('podcasts', s)}" onerror="this.remove()"></span>
|
|
<b>${esc(pdF(s, 'title'))}</b><small>${esc([pdF(s, 'allEpisodesCount') ? pdF(s, 'allEpisodesCount') + ' episodes' : '', pdF(s, 'isPaused') ? 'paused' : ''].filter(Boolean).join(' · '))}</small></a>`).join('')}</div>`
|
|
: '<p class="empty">No podcasts on your list yet. Search for one, or paste its feed address.</p>'}</div>`;
|
|
$('#pdadd').onclick = () => {
|
|
const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', `<input type="search" id="pdq" placeholder="Search podcasts, or paste https://…/feed.xml" autocomplete="off" autocapitalize="off">
|
|
<div class="err" id="pde"></div><div id="pdres" class="list" style="margin-top:8px;max-height:50vh;overflow:auto"></div>
|
|
<p class="sub" style="margin:8px 0 0">Searching uses Apple's public podcast directory, so the words you type are sent to Apple.</p>`);
|
|
const q = el.querySelector('#pdq'), res = el.querySelector('#pdres'), err = el.querySelector('#pde'); let timer, seq = 0, found = [];
|
|
const add = async (url, btn) => {
|
|
err.textContent = ''; if (btn) btn.disabled = true;
|
|
try { await pdApi('/podcasts', pdOpt('POST', { url })); closeSheet(); toast('Added; episodes will appear in a moment'); route(); }
|
|
catch (e) { err.textContent = e.status === 502 || e.status === 409 || e.status === 400 ? 'Podgrab could not add that (it may already be there)' : e.message; if (btn) btn.disabled = false; }
|
|
};
|
|
const run = async () => {
|
|
const term = q.value.trim(), my = ++seq; err.textContent = '';
|
|
if (/^https?:\/\/\S+$/i.test(term)) { res.innerHTML = `<button class="row" id="pdfeed"><span class="grow"><b>Add this feed</b><small style="white-space:normal">${esc(term)}</small></span></button>`; res.querySelector('#pdfeed').onclick = e => add(term, e.currentTarget); return; }
|
|
if (term.length < 2) { res.innerHTML = ''; return; }
|
|
res.innerHTML = '<p class="empty">Searching…</p>';
|
|
try {
|
|
found = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return;
|
|
res.innerHTML = found.length ? found.map((f, i) => `<div class="row"><img class="art" alt="" style="width:48px;height:48px;border-radius:8px;flex:none" src="${esc(f.image || '')}" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(f.title)}</b><small>${esc(f.author || '')}</small></span><button class="btn" data-i="${i}" style="min-height:36px">Add</button></div>`).join('') : '<p class="empty">Nothing found.</p>';
|
|
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b));
|
|
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
|
};
|
|
q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus();
|
|
};
|
|
}
|
|
|
|
async function pdShow(id) {
|
|
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' : 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>
|
|
<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>
|
|
<p class="sub" style="margin:6px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden">${esc(pdPlain(pdF(show, 'summary')))}</p></div></div>
|
|
<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 => { // 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);
|
|
r.querySelector('[data-seen]').onclick = async () => {
|
|
try { await pdApi('/podcastitems/' + encodeURIComponent(pdId(e)) + (pdF(e, 'isPlayed') ? '/markUnplayed' : '/markPlayed')); route(); } catch (err) { toast(err.message); }
|
|
};
|
|
});
|
|
if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]);
|
|
$('#pdmore').onclick = () => {
|
|
const el = openSheet(pdF(show, 'title'), '', `<div class="list"><button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
|
<button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button>
|
|
<button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove from my podcasts</b><small>Only from your list; it is deleted from Podgrab when nobody else follows it</small></span></button></div>`);
|
|
const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } };
|
|
act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background');
|
|
act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused');
|
|
el.querySelector('#pdd').onclick = () => { closeSheet(); confirmSheet('Remove from your podcasts?', 'It leaves your list. Its downloaded episodes are only deleted when nobody else follows it.', 'Remove', async () => {
|
|
try { await pdApi('/podcasts/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Removed'); location.hash = '#/podcast'; } catch (e) { toast(e.message); } }); };
|
|
};
|
|
}
|