clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

Podcasts, an app: episodes to play on demand, and shows to add in Settings

FelineStateMachine committed df7a6ae
Laid out as Recordings' library is, with Latest, Continue and a list per
show, and played by the recordings' player (now playOnDemand), which
shows positions in the whole episode and saves where it got to. Settings,
Podcasts adds a show by search or address and removes one. The view stays
hidden until the server says it has podcasts.
commit
df7a6ae652ed8fcba8d23a7364f2e6e41bf57ead
parent
a440a82686881f280b7cc3bab902e6070bdf26e8
author
FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
kind
feat risk score 1.9 of 3, Jev's estimate
10 files changed, +463 -36
MREADME.md1+
Mfrontend/dist/index.html12+
Mfrontend/dist/js/core.js6+
Mfrontend/dist/js/input.js6+-
Mfrontend/dist/js/playback.js33++-
Mfrontend/dist/js/player.js2+-
Afrontend/dist/js/podcasts.js384++++++++++++++++++++++++++++++
Mfrontend/dist/js/recordings.js31+--
Mfrontend/dist/js/settings.js9+-
Mfrontend/dist/style.css15+-
M README.md +1 -0
@@ -166,6 +166,7 @@ had.
166166 | G | Guide: arrows move, and Up and Down wrap round the channels. The filters (Favorites, Sports...; also `[` `]`) are Down from the top bar, which is Left from the earliest program. Enter watches what's on, or shows a program to come's actions; hold Enter, or I, for any program's: watch, record (which opens to this one, every episode or new only), favorite, hide. Home jumps to now, R / Shift R / N record, Shift F favorite, H hide (U undoes) |167167 | D | Recordings: Left (or `[` `]`) for Library, Coming up, Series. Enter shows a recording's actions: resume, from the start, watched, delete (press twice); for one recording now, watch from the start, 30 more minutes, stop (keeping it) and delete; Shift Enter starts over, W marks watched, Delete twice removes; on a series K sets how many to keep, N new episodes only |168168 | W | Weather: current conditions, alerts, the next 24 hours, 7-day, radar loop and satellite; Up and Down move through them, Left to watch the weather channel |169+| P | Podcasts: video podcasts from YouTube, on demand. The rail has Latest (every show's newest), Continue and each show; Enter shows an episode's actions: play or resume, from the start, watched. Shows are added in Settings, Podcasts, by search or a channel's or playlist's address |169170 | `,` or S | More: Settings, a tab for each area in the rail (Display, Watching, Channels, Reception, Recordings, Server, About). Up and Down pick a tab, Enter goes in, Back returns to the tabs; Page Up and Down (a controller's LB and RB, also `[` `]`) change tab |170171 | A | Settings' Reception tab: what the tuners measured, by RF channel (the ones the scan found first), with the transmitters licensed on each. Up and Down pick one, Enter watches its first channel; above them the tuner, Measure now, which reads every RF channel on a free tuner and shows its progress, and Scan for channels (press twice) |171172 | Esc | Back to TV; from a recording, back to live |
M frontend/dist/index.html +12 -0
@@ -126,6 +126,16 @@
126126 </div>127127 </section>128128 129+ <!-- Podcasts, an app: video podcasts from YouTube, played on demand, laid130+ out as Recordings' library is (js/podcasts.js). -->131+ <section id="podcasts" class="view railed" aria-label="Podcasts">132+ <nav class="rail"><div class="rail-menu p-menu" data-f="menu"></div></nav>133+ <div class="main p-main">134+ <article class="r-detail" data-f="detail"></article>135+ <div class="r-grid" data-f="grid"></div>136+ </div>137+ </section>138+129139 <!-- More: Settings, a tab for each area, in the rail; most are a list of130140 rows for a remote or a controller, Reception is what the tuners131141 measured (js/settings.js, js/reception.js). -->
@@ -174,6 +184,7 @@
174184 <button data-view="guide"><kbd data-key="g" data-label="G">G</kbd>Guide</button>175185 <button data-view="recordings"><kbd data-key="d" data-label="D">D</kbd>Recordings</button>176186 <button data-view="weather"><kbd data-key="w" data-label="W">W</kbd>Weather</button>187+ <button data-view="podcasts"><kbd data-key="p" data-label="P">P</kbd>Podcasts</button>177188 <button data-view="settings"><kbd data-key="," data-label=",">,</kbd>More</button>178189 </nav>179190
@@ -209,6 +220,7 @@
209220 <script src="js/reception.js"></script>210221 <script src="js/settings.js"></script>211222 <script src="js/recordings.js"></script>223+ <script src="js/podcasts.js"></script>212224 <script src="js/captions.js"></script>213225 <script src="js/playback.js"></script>214226 <script src="js/weather.js"></script>
M frontend/dist/js/core.js +6 -0
@@ -200,6 +200,7 @@ const state = {
200200 info: null, // backend: local engine or a server201201 config: null, // location the lineup is built for202202 dvr: null, // recording state from the server203+ podcasts: null, // the Podcasts app's shows and episodes203204 dvrKeys: new Map(), // "7.1@1791058239" -> upcoming item204205 recPick: '', // the airing whose Record is open to its choices205206 recording: null, // recording being played instead of live TV
@@ -412,6 +413,7 @@ async function init() {
412413 wireAntenna();413414 wireSettings();414415 wireRecordings();416+ wirePodcasts();415417 wireWeather();416418 wirePads();417419 wireMediaSession();
@@ -467,6 +469,8 @@ async function init() {
467469 setInterval(signalTick, 2000);468470 loadDVR();469471 loadWeather();472+ loadPodcasts();473+ setInterval(loadPodcasts, 15 * MIN);470474 }471475 472476 document.addEventListener('DOMContentLoaded', init);
@@ -503,6 +507,7 @@ function applyBoot(b) {
503507 state.info = b.info || { name: '', tuner: {}, dvr: false };504508 state.config = b.config || {};505509 document.body.classList.toggle('has-dvr', !!state.info.dvr);510+ document.body.classList.toggle('has-podcasts', !!state.info.podcasts);506511 if (b.lite === '1' || b.lite === '0') setLite(b.lite === '1');507512 applyPlatform(b.platform || (androidTV ? 'tv' : 'desktop'));508513 applyScale();
@@ -612,6 +617,7 @@ function setView(v) {
612617 if (!(state.current && state.current.weather)) showWX(false);613618 }614619 if (v === 'settings') openSettings();620+ if (v === 'podcasts') openPodcasts();615621 if (v === 'tv' && state.staleStream) restartStream();616622 if (v === 'recordings') seeFailed();617623 return undefined;
M frontend/dist/js/input.js +5 -1
@@ -79,7 +79,8 @@ function wireKeys() {
7979 return openSettings('reception', 'list');8080 }8181 if ((k === 'd' || k === 'D') && state.info.dvr) return setView(state.view === 'recordings' ? 'tv' : 'recordings');82- if ((k === 'w' || k === 'W') && state.view !== 'recordings') return setView(state.view === 'weather' ? 'tv' : 'weather');82+ if ((k === 'w' || k === 'W') && state.view !== 'recordings' && state.view !== 'podcasts') return setView(state.view === 'weather' ? 'tv' : 'weather');83+ if ((k === 'p' || k === 'P') && state.info.podcasts) return setView(state.view === 'podcasts' ? 'tv' : 'podcasts');8384 if (k === ',' || k === 's' || k === 'S') return setView(state.view === 'settings' ? 'tv' : 'settings');8485 if (k === 'm' || k === 'M') {8586 const onWX = state.current && state.current.weather;
@@ -98,6 +99,7 @@ function wireKeys() {
9899 99100 if (state.view === 'guide') return guideKey(e);100101 if (state.view === 'recordings') return recordingsKey(e);102+ if (state.view === 'podcasts') return podcastsKey(e);101103 if (state.view === 'weather') return weatherKey(e);102104 if (state.view !== 'tv') return;103105
@@ -343,6 +345,7 @@ function restoreFocus() {
343345 if (state.view === 'tv' && osd.open) osdFocus(osd.last);344346 else if (state.view === 'guide') guideZone(guideUI.zone);345347 else if (state.view === 'recordings') recZone(recUI.zone);348+ else if (state.view === 'podcasts') podZone(podUI.zone);346349 else if (state.view === 'settings') settingsRefocus();347350 else if (state.view === 'weather') wxFocus();348351 }
@@ -354,6 +357,7 @@ function viewBack() {
354357 if (state.view === 'guide' && state.recPick) { closeRecordPick(); return true; }355358 if (state.view === 'guide' && guideUI.zone !== 'grid') { guideZone('grid'); return true; }356359 if (state.view === 'recordings' && recUI.zone === 'actions') { recZone('list'); return true; }360+ if (state.view === 'podcasts' && podUI.zone === 'actions') { podZone('grid'); return true; }357361 // In a tab of Settings, Back goes to the tabs, as on Android TV.358362 if (state.view === 'settings' && settingsUI.zone === 'list') { settingsZone('tabs'); return true; }359363 return false;
M frontend/dist/js/playback.js +31 -2
@@ -65,7 +65,7 @@ function restartStream() {
6565 state.staleStream = false;6666 if (state.recording) {6767 const it = state.recording;68- return playRecording(it, (state.recOffset || 0) + (video.currentTime || 0));68+ return playOnDemand(it, (state.recOffset || 0) + (video.currentTime || 0));6969 }7070 return state.current ? tune(state.current, { quiet: true }) : undefined;7171 }
@@ -112,6 +112,32 @@ function showTimeshift() {
112112 if (el.textContent !== text) el.textContent = text;113113 }114114 115+// playOnDemand plays a recording, or a podcast's episode (kind 'episode'),116+// from a point in it, on TV; the player shows positions in the whole.117+async function playOnDemand(it, from = 0) {118+ if (!it || it.status === 'failed') return toast('That recording failed and cannot be played');119+ const token = ++state.tuneToken;120+ state.staleStream = false;121+ state.recording = it;122+ showWX(false);123+ setView('tv');124+ stage.classList.add('tuning');125+ $('#nosignal').hidden = true;126+ showBanner();127+ if (state.hls) state.hls.stopLoad();128+ try {129+ const pb = await (it.kind === 'episode' ? api().PlayEpisode(it.id, from) : api().PlayRecording(it.id, from));130+ if (token !== state.tuneToken) return;131+ state.recOffset = pb.offset || 0;132+ await play(pb, token);133+ } catch (e) {134+ if (token !== state.tuneToken) return;135+ showNoSignal({ number: it.kind === 'episode' ? 'POD' : 'REC', callSign: it.title, network: '' }, String(e && e.message ? e.message : e));136+ } finally {137+ if (token === state.tuneToken) setTimeout(() => stage.classList.remove('tuning'), 200);138+ }139+}140+115141 // ---------- recording progress ----------116142 let progressTimer = 0;117143 function saveRecordingProgress() {
@@ -120,7 +146,10 @@ function saveRecordingProgress() {
120146 const pos = (state.recOffset || 0) + video.currentTime;121147 if (pos < 1) return;122148 it.position = pos;123- api().SaveProgress(it.id, pos, it.duration || 0).catch((e) => log('warn', `progress: ${e}`));149+ const saved = it.kind === 'episode'150+ ? api().EpisodeProgress(it.id, pos, it.duration || 0, !!it.duration && pos > it.duration - 60)151+ : api().SaveProgress(it.id, pos, it.duration || 0);152+ saved.catch((e) => log('warn', `progress: ${e}`));124153 }125154 126155 video.addEventListener('timeupdate', () => {
M frontend/dist/js/player.js +1 -1
A frontend/dist/js/podcasts.js +384 -0
@@ -0,0 +1,384 @@
1+'use strict';2+3+// Podcasts, an app like Weather: video podcasts from YouTube, played on4+// demand, and the Podcasts tab of Settings where shows are added.5+6+// ---------- podcasts ----------7+// The view is the Recordings library's: the rail lists Latest (every8+// show's newest), Continue (episodes part watched) and each show; beside it9+// the episode picked, above a grid of the list's episodes. Its parts take10+// the focus in turn (data-zone): the grid, the rail's menu (Left from the11+// grid's left edge), and the episode's actions (OK): Play or Resume, From12+// the start, Watched. Back goes from the actions to the grid.13+const podUI = { zone: 'grid', act: '', list: 'latest', sel: {}, at: 0, timer: 0, searching: false };14+15+// asEpisode marks an episode for the player (state.recording), which plays16+// recordings too.17+const asEpisode = (ep) => ({ ...ep, kind: 'episode' });18+19+async function loadPodcasts() {20+ if (!state.info || !state.info.podcasts) return;21+ try {22+ state.podcasts = await api().Podcasts();23+ } catch (e) {24+ log('warn', `podcasts: ${e}`);25+ return;26+ }27+ podUI.at = Date.now();28+ if (state.view === 'podcasts') renderPodcasts();29+ if (state.view === 'settings' && settingsUI.tab === 'podcasts') renderSettings();30+ // While shows are listed, look again soon.31+ clearTimeout(podUI.timer);32+ if (state.podcasts && state.podcasts.updating) podUI.timer = setTimeout(loadPodcasts, 5000);33+}34+35+const podShows = () => (state.podcasts && state.podcasts.shows) || [];36+const showOf = (ep) => podShows().find((s) => s.id === ep.showId);37+const unwatched = (eps) => eps.filter((ep) => !ep.watched).length;38+39+// podLists are the rail's lists: [id, label, episodes], newest first.40+function podLists() {41+ const shows = podShows();42+ const all = shows.flatMap((s) => s.episodes || []).sort((a, b) => Date.parse(b.published) - Date.parse(a.published));43+ const lists = [['latest', 'Latest', all.slice(0, 60)]];44+ const going = all.filter(resumable);45+ if (going.length) lists.push(['continue', 'Continue', going]);46+ for (const s of shows) lists.push([s.id, s.title || 'Show', s.episodes || []]);47+ return lists;48+}49+50+function openPodcasts() {51+ renderPodcasts();52+ const list = podList();53+ podZone(list && list[2].length ? 'grid' : 'menu');54+ if (Date.now() - podUI.at > MIN) loadPodcasts();55+}56+57+function podList() {58+ const lists = podLists();59+ return lists.find(([k]) => k === podUI.list) || lists[0];60+}61+62+function podZone(zone, act = '') {63+ podUI.zone = zone;64+ $('#podcasts').dataset.zone = zone;65+ if (act) podUI.act = act;66+ if (state.dock >= 0 || state.view !== 'podcasts') return;67+ if (zone === 'grid') return blurIn($('#podcasts'));68+ if (zone === 'menu') return void focusEl($(`#podcasts [data-list="${CSS.escape(podList()[0])}"]`));69+ const acts = $$('#podcasts .rd-actions .act.fx').filter(isShown);70+ if (!acts.length) return podZone('grid');71+ focusEl(acts.find((b) => b.dataset.act === podUI.act) || acts[0]);72+}73+74+function podRefocus() {75+ if (state.view !== 'podcasts' || state.dock >= 0 || podUI.zone === 'grid') return;76+ if (!$('#podcasts').contains(document.activeElement) || !isShown(document.activeElement)) podZone(podUI.zone);77+}78+79+// When an episode came out: "Today", "Yesterday", "Mon", or "Oct 3".80+function published(t) {81+ const d = new Date(t);82+ if (Number.isNaN(d.getTime())) return '';83+ const days = Math.floor((new Date().setHours(0, 0, 0, 0) - new Date(d).setHours(0, 0, 0, 0)) / (24 * 3600 * 1000));84+ if (days <= 0) return 'Today';85+ if (days === 1) return 'Yesterday';86+ if (days < 7) return d.toLocaleDateString([], { weekday: 'long' });87+ return d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(days > 300 ? { year: 'numeric' } : {}) });88+}89+90+const runtime = (sec) => (sec ? (sec >= 3600 ? `${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min` : `${Math.round(sec / 60)} min`) : '');91+92+function renderPodcasts() {93+ const v = $('#podcasts');94+ const lists = podLists();95+ const cur = podList();96+ podUI.list = cur[0];97+ const eps = cur[2];98+ const at = Math.min(podUI.sel[cur[0]] || 0, Math.max(0, eps.length - 1));99+ podUI.sel[cur[0]] = at;100+101+ f(v, 'menu').innerHTML = lists.map(([k, label, list]) => {102+ const n = k === 'continue' ? list.length : unwatched(list);103+ return `<button type="button" class="rm-item fx ${k === cur[0] ? 'on' : ''}" data-list="${esc(k)}"><span class="rm-label">${esc(label)}</span><span class="rm-count">${n || ''}</span></button>`;104+ }).join('');105+106+ const ep = eps[at];107+ const show = ep && showOf(ep);108+ const act = (name, label) => `<button type="button" class="act fx" data-act="${name}">${label}</button>`;109+ const shows = podShows();110+ f(v, 'detail').innerHTML = ep ? `111+ <div>112+ <div class="rd-kicker">${esc(show ? show.title : '')} | ${published(ep.published)}${ep.duration ? ` | ${runtime(ep.duration)}` : ''}</div>113+ <div class="rd-title">${esc(ep.title)}</div>114+ <p class="rd-desc">${esc(ep.description || '')}</p>115+ <div class="rd-actions">${act('play', resumable(ep) ? `Resume at ${mmss(ep.position)}` : 'Play')}${resumable(ep) ? act('restart', 'From the start') : ''}${act('watched', ep.watched ? 'Mark unwatched' : 'Mark watched')}</div>116+ <div class="rd-hint">${podHint(ep)}</div>117+ </div>118+ <div class="rd-art" style="${ep.thumbnail ? `background-image:url('${esc(imageURL(ep.thumbnail))}')` : ''}"></div>`119+ : `<div class="rd-empty">${shows.length ? (state.podcasts && state.podcasts.updating ? 'Finding episodes...' : 'No episodes here yet.') : 'No shows yet. More, Podcasts adds them.'}</div><div></div>`;120+121+ f(v, 'grid').innerHTML = eps.map((e, i) => {122+ const s = cur[0] === 'latest' || cur[0] === 'continue' ? showOf(e) : null;123+ return `124+ <article class="r-card ${i === at ? 'sel' : ''} ${e.watched ? 'watched' : ''}" data-i="${i}">125+ <div class="r-art" style="${e.thumbnail ? `background-image:url('${esc(imageURL(e.thumbnail))}')` : ''}">${e.thumbnail ? '' : `<span>${esc(e.title)}</span>`}126+ ${resumable(e) ? `<div class="r-prog"><i style="width:${Math.min(100, (e.position / e.duration) * 100)}%"></i></div>` : ''}127+ ${e.watched ? '<b class="r-badge">Watched</b>' : e.duration ? `<b class="r-badge p-len">${mmss(e.duration)}</b>` : ''}</div>128+ <div class="r-meta">129+ <div class="r-name p-name">${esc(e.title)}</div>130+ <div class="r-when">${published(e.published)}${s ? ` | ${esc(s.title)}` : ''}</div>131+ </div>132+ </article>`;133+ }).join('') || (cur[0] !== 'latest' && cur[0] !== 'continue' && (shows.find((x) => x.id === cur[0]) || {}).error134+ ? `<p class="r-empty">${esc(shows.find((x) => x.id === cur[0]).error)}</p>` : '');135+ podRefocus();136+}137+138+function podHint(ep) {139+ if (onRemote()) return 'OK for the actions, Left for the shows';140+ return hints([['Enter', 'Enter', 'for the actions'], ...(resumable(ep) ? [['', 'Shift Enter', 'plays from the start']] : []), ['w', 'W', ep.watched ? 'unwatched' : 'watched']]);141+}142+143+function wirePodcasts() {144+ const v = $('#podcasts');145+ f(v, 'menu').addEventListener('click', (e) => {146+ const b = e.target.closest('[data-list]');147+ if (!b) return;148+ podUI.list = b.dataset.list;149+ renderPodcasts();150+ });151+ v.addEventListener('click', (e) => {152+ const b = e.target.closest('[data-act]');153+ if (b) return podAct(b.dataset.act);154+ const card = e.target.closest('[data-i]');155+ if (card) {156+ podUI.sel[podUI.list] = Number(card.dataset.i);157+ renderPodcasts();158+ }159+ return null;160+ });161+ v.addEventListener('focusin', (e) => {162+ const b = e.target.closest('[data-act]');163+ if (b) podUI.act = b.dataset.act;164+ });165+ f(v, 'grid').addEventListener('dblclick', (e) => {166+ const card = e.target.closest('[data-i]');167+ if (!card) return;168+ const ep = podList()[2][Number(card.dataset.i)];169+ if (ep) playOnDemand(asEpisode(ep), resumable(ep) ? ep.position : 0);170+ });171+}172+173+const podSelected = () => {174+ const cur = podList();175+ return cur[2][podUI.sel[cur[0]] || 0];176+};177+178+async function podAct(act) {179+ const ep = podSelected();180+ if (!ep) return;181+ if (act === 'play') return playOnDemand(asEpisode(ep), resumable(ep) ? ep.position : 0);182+ if (act === 'restart') return playOnDemand(asEpisode(ep), 0);183+ if (act === 'watched') {184+ const watched = !ep.watched;185+ ep.watched = watched;186+ if (!watched) ep.position = 0;187+ renderPodcasts();188+ try {189+ await api().EpisodeProgress(ep.id, watched ? ep.duration || 0 : 0, ep.duration || 0, watched);190+ } catch (e) {191+ toast(String(e && e.message ? e.message : e), 5000);192+ }193+ }194+ return undefined;195+}196+197+// podcastsKey handles a key in Podcasts, as recordingsKey does in the198+// library.199+function podcastsKey(e) {200+ const lists = podLists();201+ if (e.key === '[' || e.key === ']') {202+ const i = lists.findIndex(([k]) => k === podUI.list);203+ podUI.list = lists[(i + (e.key === ']' ? 1 : lists.length - 1)) % lists.length][0];204+ return renderPodcasts();205+ }206+ if (podUI.zone === 'menu') return podMenuKey(e, lists);207+ if (podUI.zone === 'actions' && podActionsKey(e)) return undefined;208+ const eps = podList()[2];209+ const sel = podUI.sel;210+ const k = podUI.list;211+ const cols = Math.max(1, Math.round(f($('#podcasts'), 'grid').clientWidth / 280));212+ const i = sel[k] || 0;213+ if (e.key === 'ArrowUp' && (!eps.length || i < cols)) { e.preventDefault(); return openDock(); }214+ if (e.key === 'ArrowLeft' && i % cols === 0) { e.preventDefault(); return podZone('menu'); }215+ if (!eps.length) return undefined;216+ switch (e.key) {217+ case 'ArrowRight': sel[k] = Math.min(eps.length - 1, i + 1); break;218+ case 'ArrowLeft': sel[k] = Math.max(0, i - 1); break;219+ case 'ArrowDown': sel[k] = Math.min(eps.length - 1, i + cols); break;220+ case 'ArrowUp': sel[k] = Math.max(0, i - cols); break;221+ case 'Enter':222+ e.preventDefault();223+ if (e.shiftKey) return podAct('restart');224+ return podZone('actions', 'play');225+ case 'w': case 'W': return podAct('watched');226+ default: return undefined;227+ }228+ e.preventDefault();229+ renderPodcasts();230+ const el = $(`#podcasts .r-grid [data-i="${sel[k]}"]`);231+ if (el) el.scrollIntoView({ block: 'nearest' });232+ return undefined;233+}234+235+// podMenuKey: Up and Down pick a list, Right or OK goes into it, Left (or236+// Up from the first) to the dock.237+function podMenuKey(e, lists) {238+ const i = lists.findIndex(([k]) => k === podUI.list);239+ switch (e.key) {240+ case 'ArrowUp': case 'ArrowDown': {241+ const j = i + (e.key === 'ArrowUp' ? -1 : 1);242+ if (j < 0) return openDock();243+ if (j >= lists.length) break;244+ podUI.list = lists[j][0];245+ renderPodcasts();246+ podZone('menu');247+ break;248+ }249+ case 'ArrowRight': case 'Enter':250+ if (podList()[2].length) podZone('grid');251+ break;252+ case 'ArrowLeft': return openDock();253+ default: return undefined;254+ }255+ e.preventDefault();256+ return undefined;257+}258+259+// podActionsKey: Left and Right along the actions, OK presses, Down (or260+// Back) to the grid, Up to the dock.261+function podActionsKey(e) {262+ const acts = $$('#podcasts .rd-actions .act.fx').filter(isShown);263+ const el = document.activeElement;264+ switch (e.key) {265+ case 'ArrowLeft': case 'ArrowRight': {266+ const next = neighbor(acts, el, e.key === 'ArrowLeft' ? -1 : 1);267+ if (next) focusEl(next);268+ break;269+ }270+ case 'ArrowUp': openDock(); break;271+ case 'ArrowDown': podZone('grid'); break;272+ case 'Enter':273+ if (acts.includes(el)) el.click(); else podZone('actions');274+ break;275+ default: return false;276+ }277+ e.preventDefault();278+ return true;279+}280+281+// renderEpisodeBanner is the banner for an episode playing.282+function renderEpisodeBanner(b, ep) {283+ const show = showOf(ep);284+ showChannelLogo(b, null);285+ f(b, 'num').classList.remove('long');286+ f(b, 'num').textContent = 'POD';287+ f(b, 'call').textContent = show ? show.title : '';288+ f(b, 'net').textContent = published(ep.published);289+ b.classList.remove('warn', 'detail');290+ f(b, 'time').textContent = ep.duration ? runtime(ep.duration) : '';291+ f(b, 'chips').innerHTML = '';292+ f(b, 'title').textContent = ep.title;293+ f(b, 'ep').textContent = '';294+ f(b, 'cast').textContent = '';295+ const pos = (state.recOffset || 0) + (video.currentTime || 0);296+ f(b, 'progress').style.width = ep.duration ? `${Math.min(100, (pos / ep.duration) * 100)}%` : '0';297+ f(b, 'desc').textContent = ep.description || '';298+ f(b, 'next').innerHTML = hints([['LeftRight', 'Arrows', 'skip'], ...(onKeys() ? [[' ', 'Space', 'pauses']] : []), ['Enter', 'Enter', 'controls'], ['Escape', 'Esc', 'returns to live TV']]);299+ f(b, 'meter').innerHTML = '';300+ f(b, 'tier').textContent = 'YouTube';301+ f(b, 'tier').style.color = '';302+ f(b, 'tx').textContent = `Through ${state.info.name}`;303+ f(b, 'atsc3').textContent = '';304+ f(b, 'note').textContent = '';305+}306+307+// ---------- podcasts in settings ----------308+// Settings, Podcasts: the shows, each with Remove (pressed twice), and Add309+// a show, which takes a search or a YouTube address and lists what it310+// finds to add.311+let podRemoveArmed = '';312+313+function podcastRows(add) {314+ if (!state.info || !state.info.podcasts) return;315+ const typeIt = androidTV ? 'OK brings up the keyboard' : 'Type it with a keyboard';316+ add('Podcasts', { id: 'podsearch', kind: 'text', label: 'Add a show', sub: `A name to search YouTube for, or a channel's or playlist's address. ${typeIt}`, name: 'podsearch', text: '', placeholder: 'Search or paste an address' });317+ for (const s of podShows()) {318+ const n = (s.episodes || []).length;319+ const armed = podRemoveArmed === s.id;320+ add('Podcasts', {321+ id: `show:${s.id}`, kind: 'action', verb: armed ? 'removes it' : 'asks to remove it', label: s.title || s.url,322+ sub: s.error || `${n} episode${n === 1 ? '' : 's'}, ${unwatched(s.episodes || [])} unwatched`,323+ value: armed ? 'Press again to remove' : 'Remove', run: () => removeShow(s),324+ });325+ }326+}327+328+async function removeShow(s) {329+ if (podRemoveArmed !== s.id) {330+ podRemoveArmed = s.id;331+ setTimeout(() => { if (podRemoveArmed === s.id) { podRemoveArmed = ''; renderSettings(); } }, 4000);332+ return renderSettings();333+ }334+ podRemoveArmed = '';335+ await api().RemovePodcast(s.id);336+ toast(`Removed ${s.title}`, 1800);337+ await loadPodcasts();338+ return undefined;339+}340+341+// searchShows looks for shows to add: an address is offered as it is, a342+// name is searched for on YouTube.343+async function searchShows(q) {344+ q = q.trim();345+ if (!q) return;346+ if (/^https?:\/\//.test(q) || /^(www\.)?youtube\.com\//.test(q)) {347+ state.podFound = [{ id: q, title: q, url: q.startsWith('http') ? q : `https://${q}`, description: 'This address' }];348+ } else {349+ toast(`Searching YouTube for ${q}...`, 15000);350+ try {351+ state.podFound = await api().SearchPodcasts(q);352+ } catch (e) {353+ return toast(String(e && e.message ? e.message : e), 6000);354+ }355+ toast(`${state.podFound.length} found`, 1500);356+ }357+ openSettingsList('podfound');358+}359+360+function podFoundRows(add) {361+ const found = state.podFound || [];362+ for (const c of found) {363+ const have = podShows().some((s) => s.url === c.url);364+ add({365+ id: c.url, kind: have ? 'info' : 'action', verb: 'adds it', label: c.title,366+ sub: [c.subscribers, c.description].filter(Boolean).join('. ').slice(0, 140),367+ value: have ? 'Added' : 'Add', run: () => addShow(c),368+ });369+ }370+ if (!found.length) add({ id: 'none', kind: 'info', label: 'Nothing found', sub: 'Try other words, or paste the show\'s address' });371+}372+373+async function addShow(c) {374+ toast(`Adding ${c.title}...`, 30000);375+ try {376+ await api().AddPodcast(c.url, 0);377+ } catch (e) {378+ return toast(String(e && e.message ? e.message : e), 6000);379+ }380+ toast(`Added ${c.title}`, 2000);381+ closeSettingsList();382+ await loadPodcasts();383+ return undefined;384+}
M frontend/dist/js/recordings.js +4 -27
@@ -203,7 +203,7 @@ function wireRecordings() {
203203 const card = e.target.closest('[data-i]');204204 if (card) {205205 const it = recLists().library[Number(card.dataset.i)];206- playRecording(it, resumable(it) ? it.position : 0);206+ playOnDemand(it, resumable(it) ? it.position : 0);207207 }208208 });209209 }
@@ -215,12 +215,12 @@ function recAct(act) {
215215 if (!it) return null;216216 const fail = (err) => toast(String(err && err.message ? err.message : err), 6000);217217 switch (act) {218- case 'play': return playRecording(it, it.status === 'recording' ? 0 : resumable(it) ? it.position : 0);218+ case 'play': return playOnDemand(it, it.status === 'recording' ? 0 : resumable(it) ? it.position : 0);219219 case 'extend': return api().ExtendRecording(it.id, 30).then((until) => {220220 toast(`Recording ${it.title} until ${clock(Date.parse(until))}`);221221 return loadDVR();222222 }).catch(fail);223- case 'restart': return playRecording(it, 0);223+ case 'restart': return playOnDemand(it, 0);224224 case 'watched': return api().MarkWatched(it.id, !it.watched).then(loadDVR).catch(fail);225225 case 'keep': case 'newonly': {226226 const steps = [0, 3, 5, 10];
@@ -277,7 +277,7 @@ function recordingsKey(e) {
277277 case 'Enter':278278 e.preventDefault();279279 // Shift Enter, on a keyboard, plays a recording from its start.280- if (tab === 'library' && e.shiftKey) return playRecording(list[sel[tab]], 0);280+ if (tab === 'library' && e.shiftKey) return playOnDemand(list[sel[tab]], 0);281281 return recZone('actions', '-');282282 case 'w': case 'W': return tab === 'library' ? recAct('watched') : undefined;283283 case 'k': case 'K': return tab === 'series' ? recAct('keep') : undefined;
@@ -350,26 +350,3 @@ function recActionsKey(e) {
350350 351351 const resumable = (it) => !!it && !it.watched && it.position > 30 && it.duration > 0 && it.position < it.duration - 60;352352 353-async function playRecording(it, from = 0) {354- if (!it || it.status === 'failed') return toast('That recording failed and cannot be played');355- const token = ++state.tuneToken;356- state.staleStream = false;357- state.recording = it;358- showWX(false);359- setView('tv');360- stage.classList.add('tuning');361- $('#nosignal').hidden = true;362- showBanner();363- if (state.hls) state.hls.stopLoad();364- try {365- const pb = await api().PlayRecording(it.id, from);366- if (token !== state.tuneToken) return;367- state.recOffset = pb.offset || 0;368- await play(pb, token);369- } catch (e) {370- if (token !== state.tuneToken) return;371- showNoSignal({ number: 'REC', callSign: it.title, network: '' }, String(e && e.message ? e.message : e));372- } finally {373- if (token === state.tuneToken) setTimeout(() => stage.classList.remove('tuning'), 200);374- }375-}
M frontend/dist/js/settings.js +7 -2
@@ -18,13 +18,13 @@ const settingsUI = { tab: 'display', zone: 'tabs', at: 0, sub: null, from: 0, ro
1818 // SETTINGS_TABS are the areas in the rail's order, [id, label]; a row's1919 // section is its tab's label.2020 const SETTINGS_TABS = [['display', 'Display'], ['watching', 'Watching'], ['channels', 'Channels'], ['reception', 'Reception'],21- ['recordings', 'Recordings'], ['server', 'Server'], ['about', 'About']];21+ ['recordings', 'Recordings'], ['podcasts', 'Podcasts'], ['server', 'Server'], ['about', 'About']];2222 2323 const AUDIO_LANGS = [['', 'As broadcast'], ['en', 'English'], ['es', 'Spanish'], ['fr', 'French'], ['ko', 'Korean'],2424 ['vi', 'Vietnamese'], ['zh', 'Chinese'], ['ru', 'Russian'], ['pt', 'Portuguese']];2525 const GUIDE_HOURS = [6, 12, 24, 36, 48, 72];2626 const WATCHED = [[0, 'Keep them'], [1, 'Delete after a day'], [7, 'Delete after a week'], [30, 'Delete after a month']];27-const SETTINGS_LISTS = { hidden: 'Hidden channels', controls: 'Remote, controller and keys', sources: 'Data sources' };27+const SETTINGS_LISTS = { hidden: 'Hidden channels', controls: 'Remote, controller and keys', sources: 'Data sources', podfound: 'Shows found' };2828 // How to hide a channel, with the keys there are.2929 const HIDE_HOW = () => (onRemote() ? 'In the guide, hold OK on a channel\'s program: Hide channel' : 'In the guide, H hides a channel');3030
@@ -80,6 +80,7 @@ function wireSettings() {
8080 });8181 list.addEventListener('change', (e) => {8282 if (e.target.name === 'server' || e.target.name === 'token') saveServer(e.target.name, e.target.value);83+ if (e.target.name === 'podsearch') searchShows(e.target.value);8384 });8485 list.addEventListener('focusout', () => setTimeout(renderSettings));8586 f(v, 'tabs').addEventListener('click', (e) => {
@@ -113,6 +114,7 @@ function pickSettingsTab(tab, zone = settingsUI.zone) {
113114 }114115 settingsUI.tab = tab;115116 const reception = tab === 'reception';117+ if (tab === 'podcasts' && Date.now() - podUI.at > MIN) loadPodcasts();116118 $('#settings').dataset.tab = tab;117119 f($('#settings'), 'list').hidden = reception;118120 $('#antenna').hidden = !reception;
@@ -204,6 +206,8 @@ function settingsRows() {
204206 });205207 }206208 209+ podcastRows(add);210+207211 const hidden = (s.hidden || []).length;208212 add('Channels', { id: 'hidden', kind: 'open', label: 'Hidden channels', sub: HIDE_HOW(), value: hidden ? `${hidden} hidden` : 'None', open: 'hidden' });209213 add('Channels', { id: 'hideshop', kind: 'action', verb: 'hides them', label: 'Hide shopping channels', sub: 'QVC, HSN, Jewelry TV and the like', value: 'Hide', run: hideShopping });
@@ -253,6 +257,7 @@ function settingsList(name) {
253257 add({ id: label, kind: 'info', label: what, html: `<span class="s-glyphs" title="${esc(padNames(pad))}">${keys.split(' ').map((k) => glyph(k === 'Space' ? ' ' : k, pad)).join('')}</span><kbd>${esc(label)}</kbd>` });254258 }255259 }260+ if (name === 'podfound') podFoundRows(add);256261 if (name === 'sources') {257262 for (const x of (state.report && state.report.sources) || []) {258263 add({ id: x.url, kind: 'action', verb: 'opens it', label: x.name, sub: x.use, value: 'Open', run: () => api().OpenURL(x.url) });
M frontend/dist/style.css +12 -3
@@ -89,7 +89,7 @@ body.mode-guide #stage {
8989 }9090 /* Every other view keeps the TV in the same top-left corner as the guide. */9191 :root { --pip-w: 224px; --pip-h: 126px; --pip-gap: 252px; }92-body.mode-recordings #stage, body.mode-settings #stage, body.mode-weather #stage {92+body.mode-recordings #stage, body.mode-podcasts #stage, body.mode-settings #stage, body.mode-weather #stage {9393 z-index: 25; top: 46px; left: 28px; width: var(--pip-w); height: var(--pip-h);9494 border-radius: 4px; box-shadow: 0 0 0 1px var(--line-2), 0 18px 40px rgba(0,0,0,.7); cursor: pointer;9595 }
@@ -186,7 +186,7 @@ body.mode-tv #banner.show { transform: none; opacity: 1; }
186186 background: radial-gradient(120% 80% at 70% 0%, #11141a 0%, var(--ink) 60%);187187 transition: opacity .35s, visibility 0s .35s;188188 }189-body.mode-guide #guide, body.mode-settings #settings, body.mode-recordings #recordings, body.mode-weather #weather {189+body.mode-guide #guide, body.mode-settings #settings, body.mode-recordings #recordings, body.mode-podcasts #podcasts, body.mode-weather #weather {190190 opacity: 1; pointer-events: auto; visibility: visible; transition: opacity .35s;191191 }192192
@@ -456,6 +456,7 @@ body.mouse-active .s-row:hover { background: var(--ink-3); } /* not under a rest
456456 }457457 body:not(.mode-tv) #dock, body.mouse-active #dock { opacity: 1; transform: translate(-50%, 0); }458458 body:not(.has-dvr) #dock [data-view="recordings"] { display: none; }459+body:not(.has-podcasts) #dock [data-view="podcasts"] { display: none; }459460 #dock button { border: 0; background: none; cursor: pointer; padding: 5px 12px 5px 8px; border-radius: 999px; font: 500 11px var(--f-mono); letter-spacing: .1em; text-transform: uppercase; color: var(--muted); display: flex; gap: 7px; align-items: center; }460461 #dock button kbd { padding: 1px 5px; border-radius: 3px; box-shadow: inset 0 0 0 1px var(--line-2); color: var(--faint); }461462 #dock button.on { background: var(--ink-4); color: var(--paper); }
@@ -591,6 +592,13 @@ body:not(.mode-guide) .g-cell.rec-on::after { animation: none; } /* hidden, so s
591592 .r-card.watched .r-art { filter: saturate(.4) brightness(.7); }592593 .r-hint { list-style: none; padding: 14px 16px; font: 400 11px var(--f-mono); color: var(--faint); letter-spacing: .06em; }593594 595+/* ---------- Podcasts ---------- */596+/* Recordings' library, for episodes: the one picked above a grid. */597+.p-main { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 24px; min-height: 0; }598+.p-menu { overflow: auto; min-height: 0; scrollbar-width: none; }599+.p-name { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; min-height: 2.6em; line-height: 1.3; }600+.r-badge.p-len { color: var(--paper); }601+594602 /* ---------- Weather ---------- */595603 /* The frame never takes focus, so channel keys keep working on WX. */596604 /* Always 854x480, the display's own size, scaled by fitWX in js/weather.js to
@@ -768,7 +776,8 @@ body:not(.dock-nav) #recordings[data-zone="list"] .r-item.sel,
768776 body:not(.dock-nav) #settings[data-zone="list"] #antenna[data-zone="table"] .a-table tr.sel {769777 box-shadow: var(--ring-in), var(--glow); background: var(--focus-fill); transform: var(--lift);770778 }771-body:not(.dock-nav) #recordings[data-zone="list"] .r-card.sel { box-shadow: var(--ring), var(--glow); transform: var(--lift); }779+body:not(.dock-nav) #recordings[data-zone="list"] .r-card.sel,780+body:not(.dock-nav) #podcasts[data-zone="grid"] .r-card.sel { box-shadow: var(--ring), var(--glow); transform: var(--lift); }772781 .g-cell.sel { z-index: 1; }773782 .s-row.focus .s-value { color: var(--paper); }774783 /* Where the focus goes back to when it's elsewhere in the view. */