Podcast: built-in screen for Podgrab (shows, episodes, playback, search)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f38ac5366
commit
675df5710b
13 files changed
+177
-19
No files matched your search
+1
-1
@@ -195,7 +195,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: viewAudio, verse: () => location.replace('#/links'), podcast: viewPodcast, 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: viewAudio, verse: () => location.replace('#/links'), podcast: args => viewPodcast(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 {}; } };
|
||||
|
||||
@@ -19,18 +19,6 @@ function showFrame(key, url, title, allow) {
|
||||
wrap.className = 'on'; document.body.classList.add('onaudio');
|
||||
}
|
||||
|
||||
// Podcast: opens in a frame when it has a secure (https) address; a plain-http address cannot be framed by a secure page.
|
||||
// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is
|
||||
// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN).
|
||||
function viewPodcast() {
|
||||
const url = cfg.podcast, u = new URL(url), canFrame = u.protocol === 'https:';
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Podcast</h1><p class="sub">${esc(u.host)}</p>
|
||||
<div class="bar" style="margin-bottom:0"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a></div>
|
||||
${canFrame ? '<p class="sub" style="margin:8px 0 0">If the page below stays blank, the app does not allow being shown inside another app. Use Open in new tab.</p>'
|
||||
: '<div class="card" style="max-width:none;margin-top:12px"><p style="margin:0">This is a plain <b>http</b> address, which a secure page cannot show inside itself. It opens in its own tab, and works at home or on your VPN.</p></div>'}</div>`;
|
||||
if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write');
|
||||
}
|
||||
|
||||
// The frame is created once and kept alive, so playback carries on while you use other tabs.
|
||||
// It loads /api/audiobooks/enter, where the server adds the site's secret sign-in link itself (the key never reaches this page).
|
||||
const ENTER = '/api/audiobooks/enter';
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<script src="/calendar.js"></script>
|
||||
<script src="/tasks.js"></script>
|
||||
<script src="/audiobooks.js"></script>
|
||||
<script src="/podcast-view.js"></script>
|
||||
<script src="/music.js"></script>
|
||||
<script src="/video.js"></script>
|
||||
<script src="/office.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 ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
|
||||
art: (thumb, w = 300) => thumb && thumb.startsWith('/api/podcast/') ? 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;
|
||||
|
||||
+3
-2
@@ -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) => 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) => String(key).startsWith('pod:') ? 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
|
||||
@@ -157,7 +157,8 @@ pl.a.addEventListener('timeupdate', () => {
|
||||
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
|
||||
if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); }
|
||||
const t = cur(), d = pl.a.duration;
|
||||
if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex
|
||||
if (t && String(t.key).startsWith('pod:')) { if (!pl.scrobbled && d && pl.a.currentTime > d * 0.9) { pl.scrobbled = true; fetch('/api/podcast/podcastitems/' + encodeURIComponent(t.key.slice(4)) + '/markPlayed').catch(() => {}); } } // a podcast episode counts as heard near its end
|
||||
else if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex
|
||||
if (Date.now() - lastPos > 1000 && 'mediaSession' in navigator && pl.a.duration) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: pl.a.duration, position: pl.a.currentTime, playbackRate: pl.a.playbackRate }); } catch {} }
|
||||
});
|
||||
addEventListener('pagehide', saveState);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'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', dur: (+pdF(e, 'duration') || 0) * 1000 });
|
||||
|
||||
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>${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><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 yet. Add one with its feed address.</p>'}</div>`;
|
||||
$('#pdadd').onclick = () => {
|
||||
const el = openSheet('Add podcast', 'Paste the podcast\'s feed (RSS) address', `<input type="url" id="pdurl" placeholder="https://example.com/feed.xml" autocomplete="off" autocapitalize="off"><div class="err" id="pde"></div><div class="bar"><button class="btn" id="pdgo">Add</button></div>`);
|
||||
const go = async () => {
|
||||
const url = el.querySelector('#pdurl').value.trim(); if (!/^https?:\/\//i.test(url)) { el.querySelector('#pde').textContent = 'Enter a feed address starting with http'; return; }
|
||||
el.querySelector('#pdgo').disabled = true;
|
||||
try { await pdApi('/podcasts', pdOpt('POST', { url })); closeSheet(); toast('Added; episodes will appear in a moment'); route(); }
|
||||
catch (e) { el.querySelector('#pde').textContent = e.status === 502 || e.status === 409 || e.status === 400 ? 'Podgrab could not add that (it may already be there)' : e.message; el.querySelector('#pdgo').disabled = false; }
|
||||
};
|
||||
el.querySelector('#pdgo').onclick = go; el.querySelector('#pdurl').onkeydown = e => { if (e.key === 'Enter') go(); }; el.querySelector('#pdurl').focus();
|
||||
};
|
||||
}
|
||||
|
||||
async function pdShow(id) {
|
||||
const [show, items0] = await Promise.all([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>
|
||||
<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 => { 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
|
||||
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 this podcast</b><small>Also deletes its downloaded episodes</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 this podcast?', 'Its episodes and downloaded files are deleted from Podgrab.', 'Remove', async () => {
|
||||
try { await pdApi('/podcasts/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Removed'); location.hash = '#/podcast'; } catch (e) { toast(e.message); } }); };
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v9';
|
||||
const CACHE = 'hub-v11';
|
||||
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'];
|
||||
|
||||
|
||||
Reference in new issue
Block a user