Podcast: Verse By Verse Ministry studies (browse, play lesson after lesson, handouts, synced place)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
This commit is contained in:
12 files changed
+210
-7
No files matched your search
+1
-1
@@ -200,7 +200,7 @@ function openMore() {
|
||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||
}
|
||||
|
||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => viewPodcast(args), radio: viewRadio, read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||
let editor;
|
||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
||||
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
<script src="/audiobooks-view.js"></script>
|
||||
<script src="/librivox-view.js"></script>
|
||||
<script src="/radio-view.js"></script>
|
||||
<script src="/vbv-view.js"></script>
|
||||
<script src="/lyrics-view.js"></script>
|
||||
<script src="/settings.js"></script>
|
||||
<script src="/files.js"></script>
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const px = {
|
||||
if (!r.ok) throw new Error('Plex error ' + r.status);
|
||||
return (await r.json()).MediaContainer || {};
|
||||
},
|
||||
art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio)\//.test(thumb) ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
|
||||
art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio|vbv)\//.test(thumb) ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
|
||||
// All music libraries on the server; the chosen one is remembered per server.
|
||||
async sections() {
|
||||
if (px.secs) return px.secs;
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cu
|
||||
pl.a.preload = 'auto';
|
||||
// Tell Plex what is playing, so it shows under Now Playing / Activity in the Plex dashboard (as "NE Hub", under the signed-in Plex account).
|
||||
let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
|
||||
const tlSend = (state, key, pos, dur) => /^(pod|radio|lv):/.test(String(key)) ? 0 : fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {});
|
||||
const tlSend = (state, key, pos, dur) => /^(pod|radio|lv|vbv):/.test(String(key)) ? 0 : fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {});
|
||||
function tlReport(state) {
|
||||
const t = cur(); if (!t) return;
|
||||
if (tlKey && tlKey !== t.key) tlSend('stopped', tlKey, tlPos, tlDur); // the previous song is over
|
||||
|
||||
@@ -40,7 +40,7 @@ async function viewPodcast(args = []) {
|
||||
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>${cfg.podcast ? `<a class="btn ghost" href="${esc(cfg.podcast)}" target="_blank" rel="noopener">Open Podgrab</a>` : ''}</div></div>`;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ async function viewPodcast(args = []) {
|
||||
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><button class="btn" id="pdadd">${icon('plus')}Add podcast</button></div>
|
||||
$('#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>`
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v40';
|
||||
const CACHE = 'hub-v41';
|
||||
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'];
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
// ---- Verse By Verse Ministry (Podcast -> Verse By Verse): free verse-by-verse Bible studies from versebyverseministry.org. Pick a study, play a lesson (the rest of the
|
||||
// study follows), open its handout. Where you got to in each lesson is kept on the server, so it follows you between devices.
|
||||
const vbApi = (p, opts) => jfetch('/api/vbv' + p, opts);
|
||||
const vbOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||
let vbProg = {}, vbQueued = {}, vbLast = 0;
|
||||
const vbPlaying = () => { const t = cur(), m = t && /^vbv:([\w-]+):(\d+)$/.exec(String(t.key)); return m ? { slug: m[1], n: +m[2], key: t.key } : null; };
|
||||
const vbLoadProg = async () => { try { for (const [k, p] of Object.entries(await vbApi('/progress') || {})) if (!vbProg[k] || p.t > vbProg[k].t) vbProg[k] = p; } catch {} };
|
||||
const vbPush = keepalive => {
|
||||
if (!Object.keys(vbQueued).length) return;
|
||||
const body = JSON.stringify(vbQueued); vbQueued = {};
|
||||
fetch('/api/vbv/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive }).then(r => { if (!r.ok) throw 0; }).catch(() => { vbQueued = { ...JSON.parse(body), ...vbQueued }; });
|
||||
};
|
||||
const vbSave = keepalive => { const n = vbPlaying(), d = pl.a.duration; if (!n || !isFinite(d) || d <= 0) return; vbProg[n.key] = vbQueued[n.key] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; vbPush(keepalive); };
|
||||
pl.a.addEventListener('timeupdate', () => { if (Date.now() - vbLast > 8000) { vbLast = Date.now(); vbSave(); } });
|
||||
pl.a.addEventListener('pause', () => vbSave()); pl.a.addEventListener('ended', () => vbSave()); addEventListener('pagehide', () => vbSave(true));
|
||||
const vbLeft = key => { const p = vbProg[key]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; };
|
||||
const vbDone = key => { const p = vbProg[key]; return !!(p && p.dur && p.pos >= p.dur - 30); };
|
||||
const vbDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; };
|
||||
const vbCover = (s, big) => `<span class="vimg" style="aspect-ratio:1/1;background:var(--panel2)"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(s.cover || '')}" onerror="this.remove()"></span>`;
|
||||
|
||||
async function viewVbv(args = []) {
|
||||
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Verse By Verse</h1><p class="empty">Loading…</p></div>';
|
||||
try { await vbLoadProg(); if (args[0] === 'study' && args[1]) return await vbStudy(args[1]); return await vbHome(); }
|
||||
catch (e) { if (!location.hash.startsWith('#/podcast/vbv')) return; v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/podcast">${icon('back')}Podcast</a><h1>Verse By Verse</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="vbretry">Try again</button><a class="btn ghost" href="https://versebyverseministry.org" target="_blank" rel="noopener">Open the website</a></div></div>`; $('#vbretry').onclick = () => route(); }
|
||||
}
|
||||
|
||||
async function vbHome() {
|
||||
const all = await vbApi('/studies'); if (!location.hash.startsWith('#/podcast/vbv')) return;
|
||||
const S = { q: '', cat: '' }, bySlug = new Map(all.studies.map(s => [s.slug, s]));
|
||||
const recent = Object.entries(vbProg).map(([k, p]) => { const m = /^vbv:([\w-]+):(\d+)$/.exec(k); return m && { slug: m[1], n: +m[2], key: k, t: p.t }; }).filter(x => x && bySlug.has(x.slug) && vbLeft(x.key)).sort((a, b) => b.t - a.t)
|
||||
.filter((x, i, a) => a.findIndex(y => y.slug === x.slug) === i).slice(0, 8);
|
||||
const card = s => `<a class="vcard" href="#/podcast/vbv/study/${esc(s.slug)}">${vbCover(s)}<b>${esc(s.title)}</b><small>${esc([s.lessons ? s.lessons + ' lessons' : '', s.teacher].filter(Boolean).join(' · '))}</small></a>`;
|
||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Podcast</a><h1 style="margin:0">Verse By Verse</h1></div>
|
||||
<p class="sub">Free verse-by-verse Bible teaching from <a href="https://versebyverseministry.org" target="_blank" rel="noopener" style="color:var(--accent)">Verse By Verse Ministry International</a>. Each study plays lesson after lesson, and most lessons have a handout.</p>
|
||||
${recent.length ? `<h2>Continue listening</h2><div class="vrow">${recent.map(x => { const s = bySlug.get(x.slug), p = vbProg[x.key]; return `<a class="vcard wide" style="width:140px;flex:none" href="#/podcast/vbv/study/${esc(x.slug)}">${vbCover(s).replace('</span>', `<i class="vprog"><b style="width:${Math.min(100, p.pos / p.dur * 100)}%"></b></i></span>`)}<b>${esc(s.title)}</b><small>Lesson ${x.n + 1}</small></a>`; }).join('')}</div>` : ''}
|
||||
<input type="search" id="vbq" placeholder="Search studies" autocomplete="off" autocapitalize="off" style="width:100%;margin-top:12px">
|
||||
<div class="seg" id="vbchips"><a href="#" data-c="" class="on">All</a>${all.categories.map(c => `<a href="#" data-c="${esc(c.id)}">${esc(c.name)}</a>`).join('')}</div><div id="vbgrid" class="vgrid"></div></div>`;
|
||||
const draw = () => { const q = S.q.toLowerCase(), list = all.studies.filter(s => (!S.cat || s.categories.includes(S.cat)) && (!q || (s.title + ' ' + s.teacher).toLowerCase().includes(q)));
|
||||
$('#vbgrid').innerHTML = list.map(card).join('') || '<p class="empty">No studies match.</p>'; };
|
||||
$('#vbq').oninput = e => { S.q = e.target.value.trim(); draw(); };
|
||||
$('#vbchips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.cat = a.dataset.c; $('#vbchips').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); draw(); });
|
||||
draw();
|
||||
}
|
||||
|
||||
async function vbStudy(slug) {
|
||||
const s = await vbApi('/study/' + encodeURIComponent(slug)); if (!location.hash.startsWith('#/podcast/vbv')) return;
|
||||
const key = l => `vbv:${slug}:${l.n}`, partial = s.lessons.filter(l => vbLeft(key(l))).sort((a, b) => vbProg[key(b)].t - vbProg[key(a)].t)[0], started = s.lessons.some(l => vbProg[key(l)]);
|
||||
const nextUp = () => partial || s.lessons.find(l => !vbDone(key(l))) || s.lessons[0];
|
||||
const row = l => { const p = vbLeft(key(l)), done = vbDone(key(l)); return `<div class="row vbrow" data-n="${l.n}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play lesson ${l.n + 1}">${icon('play')}</button>
|
||||
<span class="grow"><b${done ? ' style="color:var(--muted)"' : ''}>${l.n + 1}. ${esc(l.title)}</b><small>${esc([vbDate(l.date), done ? 'played' : p ? Math.max(1, Math.round((p.dur - p.pos) / 60)) + ' min left' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||
<a class="btn ghost" data-notes href="/api/vbv/notes/${esc(slug)}/${l.n}" target="_blank" rel="noopener" style="min-height:36px;padding:0 12px" title="Lesson handout (PDF)">Notes</a></div>`; };
|
||||
const resume = nextUp(), rp = vbLeft(key(resume));
|
||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast/vbv">${icon('back')}Studies</a></div>
|
||||
<div style="display:flex;gap:16px;align-items:flex-start"><span style="width:120px;flex:none">${vbCover(s)}</span><div style="min-width:0"><h1 style="margin:0">${esc(s.title)}</h1><p class="sub" style="margin:2px 0 0">${esc(s.teacher)} · ${s.lessons.length} lessons</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(s.description)}</p></div></div>
|
||||
<div class="bar"><button class="btn" id="vbgo">${icon('play')}${started ? 'Continue' : 'Start'}${resume.n ? ' · lesson ' + (resume.n + 1) : ''}</button></div><h2>Lessons</h2><div class="list">${s.lessons.map(row).join('')}</div>
|
||||
<p class="sub" style="margin-top:14px">Teaching and handouts © Verse By Verse Ministry International, shared freely at <a href="https://versebyverseministry.org/bible-studies/${esc(slug)}" target="_blank" rel="noopener" style="color:var(--accent)">versebyverseministry.org</a>.</p></div>`;
|
||||
const play = l => { // this lesson (from where you left off in it), then the rest of the study in order
|
||||
const p = vbLeft(key(l)), np = vbPlaying(); if (np && np.key === key(l)) { if (pl.a.paused) pl.a.play().catch(() => {}); return; }
|
||||
const q = s.lessons.slice(l.n).map(x => ({ key: key(x), title: x.title, artist: s.title, album: 'Verse By Verse Ministry', thumb: s.cover, src: `/api/vbv/audio/${encodeURIComponent(slug)}/${x.n}`, nocache: true, dur: 0 }));
|
||||
playQueue(q, 0); if (p) pl.a.addEventListener('loadedmetadata', () => { const t = vbPlaying(); if (t && t.key === key(l)) pl.a.currentTime = p.pos; }, { once: true });
|
||||
};
|
||||
$('#vbgo').onclick = () => play(resume);
|
||||
document.querySelectorAll('.vbrow').forEach(r => { const l = s.lessons[+r.dataset.n]; r.querySelector('[data-play]').onclick = () => play(l);
|
||||
r.querySelector('[data-notes]').onclick = async e => { e.preventDefault(); try { const res = await fetch(e.currentTarget.href, { redirect: 'manual' }); if (res.status === 404) return toast('This lesson has no handout'); window.open(e.currentTarget.href, '_blank', 'noopener'); } catch { window.open(e.currentTarget.href, '_blank', 'noopener'); } }; });
|
||||
}
|
||||
Reference in new issue
Block a user