clone

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

main
airwaves / frontend / dist / js / podcasts.js
podcasts.js 18.8K
1'use strict';23// Podcasts, an app like Weather: video podcasts from YouTube, played on4// demand, and the Podcasts tab of Settings where shows are added.56// ---------- 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 Refresh under it, and the episode's actions (OK):12// Play or Resume, From the start, Watched. Back goes from the actions to13// the grid.14const podUI = { zone: 'grid', act: '', list: 'latest', sel: {}, at: 0, timer: 0, searching: false, refreshing: false };1516// asEpisode marks an episode for the player (state.recording), which plays17// recordings too.18const asEpisode = (ep) => ({ ...ep, kind: 'episode' });1920async function loadPodcasts() {21 if (!state.info || !state.info.podcasts) return;22 try {23 state.podcasts = await api().Podcasts();24 } catch (e) {25 log('warn', `podcasts: ${e}`);26 return;27 }28 podUI.at = Date.now();29 if (state.view === 'podcasts') renderPodcasts();30 if (state.view === 'settings' && settingsUI.tab === 'podcasts') renderSettings();31 // While shows are listed, look again soon.32 clearTimeout(podUI.timer);33 if (state.podcasts && state.podcasts.updating) podUI.timer = setTimeout(loadPodcasts, 5000);34}3536const podShows = () => (state.podcasts && state.podcasts.shows) || [];37const showOf = (ep) => podShows().find((s) => s.id === ep.showId);38const unwatched = (eps) => eps.filter((ep) => !ep.watched).length;3940// podLists are the rail's lists: [id, label, episodes], newest first.41function podLists() {42 const shows = podShows();43 const all = shows.flatMap((s) => s.episodes || []).sort((a, b) => Date.parse(b.published) - Date.parse(a.published));44 const lists = [['latest', 'Latest', all.slice(0, 60)]];45 const going = all.filter(resumable);46 if (going.length) lists.push(['continue', 'Continue', going]);47 for (const s of shows) lists.push([s.id, s.title || 'Show', s.episodes || []]);48 return lists;49}5051function openPodcasts() {52 renderPodcasts();53 const list = podList();54 podZone(list && list[2].length ? 'grid' : 'menu');55 if (Date.now() - podUI.at > MIN) loadPodcasts();56}5758function podList() {59 const lists = podLists();60 return lists.find(([k]) => k === podUI.list) || lists[0];61}6263function podZone(zone, act = '') {64 podUI.zone = zone;65 $('#podcasts').dataset.zone = zone;66 if (act) podUI.act = act;67 if (state.dock >= 0 || state.view !== 'podcasts') return;68 if (zone === 'grid') return blurIn($('#podcasts'));69 if (zone === 'menu') return void focusEl($(`#podcasts [data-list="${CSS.escape(podList()[0])}"]`));70 if (zone === 'refresh') return void focusEl(f($('#podcasts'), 'refresh'));71 const acts = $$('#podcasts .rd-actions .act.fx').filter(isShown);72 if (!acts.length) return podZone('grid');73 focusEl(acts.find((b) => b.dataset.act === podUI.act) || acts[0]);74}7576function podRefocus() {77 if (state.view !== 'podcasts' || state.dock >= 0 || podUI.zone === 'grid') return;78 if (!$('#podcasts').contains(document.activeElement) || !isShown(document.activeElement)) podZone(podUI.zone);79}8081// When an episode came out: "Today", "Yesterday", "Mon", or "Oct 3".82function published(t) {83 const d = new Date(t);84 if (Number.isNaN(d.getTime())) return '';85 const days = Math.floor((new Date().setHours(0, 0, 0, 0) - new Date(d).setHours(0, 0, 0, 0)) / (24 * 3600 * 1000));86 if (days <= 0) return 'Today';87 if (days === 1) return 'Yesterday';88 if (days < 7) return d.toLocaleDateString([], { weekday: 'long' });89 return d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(days > 300 ? { year: 'numeric' } : {}) });90}9192const runtime = (sec) => (sec ? (sec >= 3600 ? `${Math.floor(sec / 3600)} h ${Math.round((sec % 3600) / 60)} min` : `${Math.round(sec / 60)} min`) : '');9394function renderPodcasts() {95 const v = $('#podcasts');96 const lists = podLists();97 const cur = podList();98 podUI.list = cur[0];99 const eps = cur[2];100 const at = Math.min(podUI.sel[cur[0]] || 0, Math.max(0, eps.length - 1));101 podUI.sel[cur[0]] = at;102103 f(v, 'menu').innerHTML = lists.map(([k, label, list]) => {104 const n = k === 'continue' ? list.length : unwatched(list);105 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>`;106 }).join('');107108 const ep = eps[at];109 const show = ep && showOf(ep);110 const act = (name, label) => `<button type="button" class="act fx" data-act="${name}">${label}</button>`;111 const shows = podShows();112 f(v, 'detail').innerHTML = ep ? `113 <div>114 <div class="rd-kicker">${esc(show ? show.title : '')} | ${published(ep.published)}${ep.duration ? ` | ${runtime(ep.duration)}` : ''}</div>115 <div class="rd-title">${esc(ep.title)}</div>116 <p class="rd-desc">${esc(ep.description || '')}</p>117 <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>118 <div class="rd-hint">${podHint(ep)}</div>119 </div>120 <div class="rd-art" style="${ep.thumbnail ? `background-image:url('${esc(imageURL(ep.thumbnail))}')` : ''}"></div>`121 : `<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>`;122123 f(v, 'grid').innerHTML = eps.map((e, i) => {124 const s = cur[0] === 'latest' || cur[0] === 'continue' ? showOf(e) : null;125 return `126 <article class="r-card ${i === at ? 'sel' : ''} ${e.watched ? 'watched' : ''}" data-i="${i}">127 <div class="r-art" style="${e.thumbnail ? `background-image:url('${esc(imageURL(e.thumbnail))}')` : ''}">${e.thumbnail ? '' : `<span>${esc(e.title)}</span>`}128 ${resumable(e) ? `<div class="r-prog"><i style="width:${Math.min(100, (e.position / e.duration) * 100)}%"></i></div>` : ''}129 ${e.watched ? '<b class="r-badge">Watched</b>' : e.duration ? `<b class="r-badge p-len">${mmss(e.duration)}</b>` : ''}</div>130 <div class="r-meta">131 <div class="r-name p-name">${esc(e.title)}</div>132 <div class="r-when">${published(e.published)}${s ? ` | ${esc(s.title)}` : ''}</div>133 </div>134 </article>`;135 }).join('') || (cur[0] !== 'latest' && cur[0] !== 'continue' && (shows.find((x) => x.id === cur[0]) || {}).error136 ? `<p class="r-empty">${esc(shows.find((x) => x.id === cur[0]).error)}</p>` : '');137 renderPodRefresh(v);138 podRefocus();139}140141// renderPodRefresh shows Refresh, under the lists when there are shows,142// and when every show was last listed.143function renderPodRefresh(v) {144 const listed = state.podcasts && Date.parse(state.podcasts.listed);145 $('.p-refresh', v).hidden = !podShows().length;146 f(v, 'refresh').classList.toggle('busy', podUI.refreshing);147 f(v, 'refresh').textContent = podUI.refreshing ? 'Refreshing' : 'Refresh';148 f(v, 'rnote').textContent = podUI.refreshing ? 'Looking for new episodes'149 : listed ? `Every show checked ${ago(listed)}` : '';150}151152// refreshPodcasts has the server list every show again now, and says what153// came of it. The new episodes are looked up after (their descriptions,154// their dates to the minute), so they're loaded again in a bit.155async function refreshPodcasts() {156 if (podUI.refreshing || !podShows().length) return;157 const had = new Set(podShows().flatMap((s) => (s.episodes || []).map((ep) => ep.id)));158 podUI.refreshing = true;159 renderPodcasts();160 let st = null;161 try {162 st = await api().RefreshPodcasts();163 } catch (e) {164 toast(String(e && e.message ? e.message : e), 6000);165 }166 podUI.refreshing = false;167 if (st) {168 state.podcasts = st;169 podUI.at = Date.now();170 const fresh = podShows().flatMap((s) => s.episodes || []).filter((ep) => !had.has(ep.id)).length;171 const failed = podShows().filter((s) => s.error);172 const why = failed.length ? `. ${failed.length === 1 ? failed[0].title : `${failed.length} shows`} couldn't be checked` : '';173 toast(`${fresh ? `${fresh} new episode${fresh === 1 ? '' : 's'}` : 'No new episodes'}${why}`, failed.length ? 5000 : 2500);174 if (fresh) {175 podUI.sel = {}; // the newest, where they are176 clearTimeout(podUI.timer);177 podUI.timer = setTimeout(loadPodcasts, 20_000);178 }179 }180 if (state.view === 'podcasts') renderPodcasts();181}182183function podHint(ep) {184 if (onRemote()) return 'OK for the actions, Left for the shows';185 return hints([['Enter', 'Enter', 'for the actions'], ...(resumable(ep) ? [['', 'Shift Enter', 'plays from the start']] : []), ['w', 'W', ep.watched ? 'unwatched' : 'watched'], ['r', 'R', 'refreshes']]);186}187188function wirePodcasts() {189 const v = $('#podcasts');190 f(v, 'menu').addEventListener('click', (e) => {191 const b = e.target.closest('[data-list]');192 if (!b) return;193 podUI.list = b.dataset.list;194 renderPodcasts();195 });196 f(v, 'refresh').addEventListener('click', refreshPodcasts);197 v.addEventListener('click', (e) => {198 const b = e.target.closest('[data-act]');199 if (b) return podAct(b.dataset.act);200 const card = e.target.closest('[data-i]');201 if (card) {202 const was = podUI.sel[podUI.list] || 0;203 podUI.sel[podUI.list] = Number(card.dataset.i);204 renderPodcasts();205 if (tappedAgain(was, podUI.sel[podUI.list])) podAct('play');206 }207 return null;208 });209 v.addEventListener('focusin', (e) => {210 const b = e.target.closest('[data-act]');211 if (b) podUI.act = b.dataset.act;212 });213 f(v, 'grid').addEventListener('dblclick', (e) => {214 const card = e.target.closest('[data-i]');215 if (!card) return;216 const ep = podList()[2][Number(card.dataset.i)];217 if (ep) playOnDemand(asEpisode(ep), resumable(ep) ? ep.position : 0);218 });219}220221const podSelected = () => {222 const cur = podList();223 return cur[2][podUI.sel[cur[0]] || 0];224};225226async function podAct(act) {227 const ep = podSelected();228 if (!ep) return;229 if (act === 'play') return playOnDemand(asEpisode(ep), resumable(ep) ? ep.position : 0);230 if (act === 'restart') return playOnDemand(asEpisode(ep), 0);231 if (act === 'watched') {232 const watched = !ep.watched;233 ep.watched = watched;234 if (!watched) ep.position = 0;235 renderPodcasts();236 try {237 await api().EpisodeProgress(ep.id, watched ? ep.duration || 0 : 0, ep.duration || 0, watched);238 } catch (e) {239 toast(String(e && e.message ? e.message : e), 5000);240 }241 }242 return undefined;243}244245// podcastsKey handles a key in Podcasts, as recordingsKey does in the246// library.247function podcastsKey(e) {248 const lists = podLists();249 if (e.key === 'r' || e.key === 'R') return void refreshPodcasts();250 if (e.key === '[' || e.key === ']') {251 const i = lists.findIndex(([k]) => k === podUI.list);252 podUI.list = lists[(i + (e.key === ']' ? 1 : lists.length - 1)) % lists.length][0];253 return renderPodcasts();254 }255 if (podUI.zone === 'menu') return podMenuKey(e, lists);256 if (podUI.zone === 'refresh') return podRefreshKey(e);257 if (podUI.zone === 'actions' && podActionsKey(e)) return undefined;258 const eps = podList()[2];259 const sel = podUI.sel;260 const k = podUI.list;261 const cols = Math.max(1, Math.round(f($('#podcasts'), 'grid').clientWidth / 280));262 const i = sel[k] || 0;263 if (e.key === 'ArrowUp' && (!eps.length || i < cols)) { e.preventDefault(); return openDock(); }264 if (e.key === 'ArrowLeft' && i % cols === 0) { e.preventDefault(); return podZone('menu'); }265 if (!eps.length) return undefined;266 switch (e.key) {267 case 'ArrowRight': sel[k] = Math.min(eps.length - 1, i + 1); break;268 case 'ArrowLeft': sel[k] = Math.max(0, i - 1); break;269 case 'ArrowDown': sel[k] = Math.min(eps.length - 1, i + cols); break;270 case 'ArrowUp': sel[k] = Math.max(0, i - cols); break;271 case 'Enter':272 e.preventDefault();273 if (e.shiftKey) return podAct('restart');274 return podZone('actions', 'play');275 case 'w': case 'W': return podAct('watched');276 default: return undefined;277 }278 e.preventDefault();279 renderPodcasts();280 const el = $(`#podcasts .r-grid [data-i="${sel[k]}"]`);281 if (el) el.scrollIntoView({ block: 'nearest' });282 return undefined;283}284285// podMenuKey: Up and Down pick a list (Down from the last to Refresh),286// Right or OK goes into it, Left (or Up from the first) to the dock.287function podMenuKey(e, lists) {288 const i = lists.findIndex(([k]) => k === podUI.list);289 switch (e.key) {290 case 'ArrowUp': case 'ArrowDown': {291 const j = i + (e.key === 'ArrowUp' ? -1 : 1);292 if (j < 0) return openDock();293 if (j >= lists.length) {294 if (isShown(f($('#podcasts'), 'refresh'))) podZone('refresh');295 break;296 }297 podUI.list = lists[j][0];298 renderPodcasts();299 podZone('menu');300 break;301 }302 case 'ArrowRight': case 'Enter':303 if (podList()[2].length) podZone('grid');304 break;305 case 'ArrowLeft': return openDock();306 default: return undefined;307 }308 e.preventDefault();309 return undefined;310}311312// podRefreshKey: OK refreshes, Up goes back to the lists, Right into the313// list picked, Left to the dock.314function podRefreshKey(e) {315 switch (e.key) {316 case 'Enter': f($('#podcasts'), 'refresh').click(); break;317 case 'ArrowUp': podZone('menu'); break;318 case 'ArrowRight': if (podList()[2].length) podZone('grid'); break;319 case 'ArrowLeft': openDock(); break;320 case 'ArrowDown': break;321 default: return undefined;322 }323 e.preventDefault();324 return undefined;325}326327// podActionsKey: Left and Right along the actions, OK presses, Down (or328// Back) to the grid, Up to the dock.329function podActionsKey(e) {330 const acts = $$('#podcasts .rd-actions .act.fx').filter(isShown);331 const el = document.activeElement;332 switch (e.key) {333 case 'ArrowLeft': case 'ArrowRight': {334 const next = neighbor(acts, el, e.key === 'ArrowLeft' ? -1 : 1);335 if (next) focusEl(next);336 break;337 }338 case 'ArrowUp': openDock(); break;339 case 'ArrowDown': podZone('grid'); break;340 case 'Enter':341 if (acts.includes(el)) el.click(); else podZone('actions');342 break;343 default: return false;344 }345 e.preventDefault();346 return true;347}348349// renderEpisodeBanner is the banner for an episode playing.350function renderEpisodeBanner(b, ep) {351 const show = showOf(ep);352 showChannelLogo(b, null);353 f(b, 'num').classList.remove('long');354 f(b, 'num').textContent = 'POD';355 f(b, 'call').textContent = show ? show.title : '';356 f(b, 'net').textContent = published(ep.published);357 b.classList.remove('warn', 'detail');358 f(b, 'time').textContent = ep.duration ? runtime(ep.duration) : '';359 f(b, 'chips').innerHTML = '';360 f(b, 'title').textContent = ep.title;361 f(b, 'ep').textContent = '';362 f(b, 'cast').textContent = '';363 const pos = (state.recOffset || 0) + (video.currentTime || 0);364 f(b, 'progress').style.width = ep.duration ? `${Math.min(100, (pos / ep.duration) * 100)}%` : '0';365 f(b, 'desc').textContent = ep.description || '';366 f(b, 'next').innerHTML = hints([['LeftRight', 'Arrows', 'skip'], ...(onKeys() ? [[' ', 'Space', 'pauses']] : []), ['Enter', 'Enter', 'controls'], ['Escape', 'Esc', 'returns to live TV']]);367 f(b, 'meter').innerHTML = '';368 f(b, 'tier').textContent = 'YouTube';369 f(b, 'tier').style.color = '';370 f(b, 'tx').textContent = `Through ${state.info.name}`;371 f(b, 'atsc3').textContent = '';372 f(b, 'note').textContent = '';373}374375// ---------- podcasts in settings ----------376// Settings, Podcasts: the shows, each with Remove (pressed twice), and Add377// a show, which takes a search or a YouTube address and lists what it378// finds to add.379let podRemoveArmed = '';380381function podcastRows(add) {382 if (!state.info || !state.info.podcasts) return;383 const typeIt = androidTV ? 'OK brings up the keyboard' : 'Type it with a keyboard';384 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' });385 for (const s of podShows()) {386 const n = (s.episodes || []).length;387 const armed = podRemoveArmed === s.id;388 add('Podcasts', {389 id: `show:${s.id}`, kind: 'action', verb: armed ? 'removes it' : 'asks to remove it', label: s.title || s.url,390 sub: s.error || `${n} episode${n === 1 ? '' : 's'}, ${unwatched(s.episodes || [])} unwatched`,391 value: armed ? 'Press again to remove' : 'Remove', run: () => removeShow(s),392 });393 }394}395396async function removeShow(s) {397 if (podRemoveArmed !== s.id) {398 podRemoveArmed = s.id;399 setTimeout(() => { if (podRemoveArmed === s.id) { podRemoveArmed = ''; renderSettings(); } }, 4000);400 return renderSettings();401 }402 podRemoveArmed = '';403 await api().RemovePodcast(s.id);404 toast(`Removed ${s.title}`, 1800);405 await loadPodcasts();406 return undefined;407}408409// searchShows looks for shows to add: an address is offered as it is, a410// name is searched for on YouTube.411async function searchShows(q) {412 q = q.trim();413 if (!q) return;414 if (/^https?:\/\//.test(q) || /^(www\.)?youtube\.com\//.test(q)) {415 state.podFound = [{ id: q, title: q, url: q.startsWith('http') ? q : `https://${q}`, description: 'This address' }];416 } else {417 toast(`Searching YouTube for ${q}...`, 15000);418 try {419 state.podFound = await api().SearchPodcasts(q);420 } catch (e) {421 return toast(String(e && e.message ? e.message : e), 6000);422 }423 toast(`${state.podFound.length} found`, 1500);424 }425 openSettingsList('podfound');426}427428// subscribers is a channel's following as YouTube says it: "3.39M429// subscribers".430const subscribers = (n) => `${new Intl.NumberFormat([], { notation: 'compact', maximumSignificantDigits: 3 }).format(n)} subscriber${n === 1 ? '' : 's'}`;431432function podFoundRows(add) {433 const found = state.podFound || [];434 for (const c of found) {435 // A channel found is its videos as a show.436 const have = podShows().some((s) => s.url === c.url || s.url === `${c.url}/videos`);437 add({438 id: c.url, kind: have ? 'info' : 'action', verb: 'adds it', label: c.title,439 sub: [c.subscribers ? subscribers(c.subscribers) : '', c.description].filter(Boolean).join('. ').slice(0, 140),440 value: have ? 'Added' : 'Add', run: () => addShow(c),441 });442 }443 if (!found.length) add({ id: 'none', kind: 'info', label: 'Nothing found', sub: 'Try other words, or paste the show\'s address' });444}445446// addShow adds a show found, one at a time: it's listed first, a few447// seconds.448let podAdding = '';449async function addShow(c) {450 if (podAdding) return toast(`Still adding ${podAdding}...`, 2000);451 podAdding = c.title;452 toast(`Adding ${c.title}...`, 120_000);453 let show;454 try {455 show = await api().AddPodcast(c.url, 0);456 } catch (e) {457 return toast(String(e && e.message ? e.message : e), 6000);458 } finally {459 podAdding = '';460 }461 const n = (show.episodes || []).length;462 toast(`Added ${show.title || c.title}, ${n} episode${n === 1 ? '' : 's'}`, 2500);463 closeSettingsList();464 await loadPodcasts();465 return undefined;466}