'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 = '

Podcast

Loading…

'; 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 = `

Podcast

${esc(e.message)}

Verse By Verse${cfg.podcast ? `Open Podgrab` : ''}
`; $('#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 = `

Podcast

Verse By Verse
${shows.length ? `
${shows.map(s => ` ${esc(pdF(s, 'title'))}${esc([pdF(s, 'allEpisodesCount') ? pdF(s, 'allEpisodesCount') + ' episodes' : '', pdF(s, 'isPaused') ? 'paused' : ''].filter(Boolean).join(' · '))}`).join('')}
` : '

No podcasts on your list yet. Search for one, or paste its feed address.

'}
`; $('#pdtr').onclick = pdTranscriptSearch; $('#pdadd').onclick = () => { const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', `

Searching uses public podcast directories (Apple's, and Podcast Index when the server has a key), so the words you type are sent to them.

`); 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 show = list => { found = list; res.innerHTML = found.length ? found.map((f, i) => `
${esc(f.title)}${esc(f.author || '')}
`).join('') : '

Nothing found.

'; res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b)); }; pdApi('/discover/topics').then(ts => { const box = el.querySelector('#pdtopics'); if (!box || !ts.length) return; box.innerHTML = ts.map(t => ``).join(''); box.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { const my = ++seq; q.value = ''; err.textContent = ''; res.innerHTML = '

Looking…

'; try { const l = await pdApi('/discover?topic=' + encodeURIComponent(b.dataset.t)); if (my === seq) show(l); } catch (e) { if (my === seq) res.innerHTML = `

${esc(e.message)}

`; } }); }).catch(() => {}); const run = async () => { const term = q.value.trim(), my = ++seq; err.textContent = ''; if (/^https?:\/\/\S+$/i.test(term)) { res.innerHTML = ``; res.querySelector('#pdfeed').onclick = e => add(term, e.currentTarget); return; } if (term.length < 2) { res.innerHTML = ''; return; } res.innerHTML = '

Searching…

'; try { const l = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return; show(l); } catch (e) { if (my === seq) res.innerHTML = `

${esc(e.message)}

`; } }; q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus(); }; } // Search what has been said in every transcript you have: tap a result to play that episode from that moment. const pdClock = ms => { const s = Math.floor(ms / 1000), 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'); }; function pdTranscriptSearch() { const el = openSheet("Search what's said", 'Finds a word or phrase in every transcript you have', `
`); const q = el.querySelector('#trq'), res = el.querySelector('#trres'); let timer, seq = 0, found = []; const hl = (text, w) => { const re = new RegExp('(' + w.split(/\s+/).filter(x => x.length > 1).map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'gi'); return esc(text).replace(re, '$1'); }; const play = (r, h) => { closeSheet(); const key = r.key, track = { key, title: r.title, artist: r.artist, album: '', thumb: r.thumb, src: r.src, nocache: true }; if (pdPlaying() === key.slice(4) && String(cur().key) === key) { pl.a.currentTime = h.t / 1000; pl.a.play().catch(() => {}); return; } playQueue([track], 0); pl.a.addEventListener('loadedmetadata', () => { const c = cur(); if (c && c.key === key) pl.a.currentTime = h.t / 1000; }, { once: true }); }; const run = async () => { const term = q.value.trim(), my = ++seq; if (term.length < 2) { res.innerHTML = ''; return; } res.innerHTML = '

Searching…

'; try { const out = (await jfetch('/api/transcript/search?q=' + encodeURIComponent(term))).results; if (my !== seq) return; found = out; res.innerHTML = out.length ? out.map((r, i) => `
${esc(r.title)}${esc(r.artist)}${r.n > r.hits.length ? ' · ' + r.n + ' places' : ''}
${r.hits.map((h, j) => ``).join('')}
`).join('') : '

Nothing found. Only episodes that already have a transcript can be searched.

'; res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => play(found[b.dataset.i], found[b.dataset.i].hits[b.dataset.j])); } catch (e) { if (my === seq) res.innerHTML = `

${esc(e.message)}

`; } }; 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 => `
${esc(pdF(e, 'title'))}${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(' · '))}
`; $('#view').innerHTML = `
${icon('back')}Back

${esc(pdF(show, 'title'))}

${esc(pdF(show, 'author') || '')}

${esc(pdPlain(pdF(show, 'summary')))}

${items.length ? `` : ''}

Episodes

${items.map(ep).join('') || '

No episodes yet. Podgrab may still be fetching the feed.

'}
`; 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); 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); } }; }); if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]); $('#pdmore').onclick = () => { const el = openSheet(pdF(show, 'title'), '', `
`); 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); } }); }; }; }