1;23// The Recordings view.45// ---------- recordings view ----------6function renderRecordingBanner(b, it) {7 const d = new Date(it.start);8 showChannelLogo(b, null);9 f(b, 'num').classList.remove('long');10 f(b, 'num').textContent = 'REC';11 f(b, 'call').textContent = `${it.channel} ${it.callSign || ''}`;12 f(b, 'net').textContent = d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });13 b.classList.remove('warn', 'detail');14 f(b, 'time').textContent = `Recorded ${clock(d)}${it.sizeBytes ? ' | ' + sizeLabel(it.sizeBytes) : ''}`;15 f(b, 'chips').innerHTML = '';16 f(b, 'title').textContent = it.title;17 f(b, 'ep').textContent = it.subtitle || '';18 const pos = (state.recOffset || 0) + (video.currentTime || 0);19 f(b, 'progress').style.width = it.duration ? `${Math.min(100, (pos / it.duration) * 100)}%` : '0';20 f(b, 'desc').textContent = it.description || '';21 f(b, 'next').innerHTML = hints([['LeftRight', 'Arrows', 'skip'], ...(onKeys() ? [[' ', 'Space', 'pauses']] : []), ['Enter', 'Enter', 'controls'], ['Escape', 'Esc', 'returns to live TV']]);22 f(b, 'meter').innerHTML = '';23 f(b, 'tier').textContent = sizeLabel(it.sizeBytes);24 f(b, 'tier').style.color = '';25 f(b, 'tx').textContent = `On ${state.info.name}`;26 f(b, 'atsc3').textContent = '';27 f(b, 'note').textContent = '';28}2930function sizeLabel(bytes) {31 if (!bytes) return '';32 return bytes > 1e9 ? `${(bytes / 1e9).toFixed(1)} GB` : `${Math.round(bytes / 1e6)} MB`;33}3435const dayLabel = (t) => {36 const d = new Date(t);37 const today = new Date();38 const tomorrow = new Date(Date.now() + 86_400_000);39 if (d.toDateString() === today.toDateString()) return 'Today';40 if (d.toDateString() === tomorrow.toDateString()) return 'Tomorrow';41 return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });42};4344// A delete, a skip or a stopped series asks for a second press: the45// button says so (armed) until then, for a few seconds.46let pendingDelete = '';47let pendingTimer = 0;4849const REC_TABS = [['library', 'Library'], ['upcoming', 'Coming up'], ['series', 'Series']];5051function recLists() {52 const st = state.dvr || {};53 return {54 library: st.recorded || [],55 upcoming: st.upcoming || [],56 series: (st.rules || []).filter((r) => r.kind === 'series'),57 };58}5960// The view's parts take the focus in turn (data-zone): the list, the menu61// of lists in the rail (Left from the list's left edge), and the selected62// item's actions (OK): for a recording Play (or Resume), From the start,63// Watched and Delete, above the grid; for one to come, Don't record; for64// one under way, Watch from the start, 30 more minutes, Stop recording and65// Delete; for a series, how many to keep, new episodes only, and Stop.66const recUI = { zone: 'list', act: '' };6768function openRecordings() {69 const tab = state.recTab || 'library';70 renderRecordings();71 recZone(recLists()[tab].length ? 'list' : 'menu');72}7374function recZone(zone, act = '') {75 recUI.zone = zone;76 $('#recordings').dataset.zone = zone;77 if (act) recUI.act = act;78 if (state.dock >= 0 || state.view !== 'recordings') return;79 if (zone === 'list') return blurIn($('#recordings'));80 if (zone === 'menu') return void focusEl($(`#recordings [data-tab="${state.recTab || 'library'}"]`));81 const acts = recActs();82 if (!acts.length) return recZone('list');83 focusEl(acts.find((b) => b.dataset.act === recUI.act) || acts[0]);84}8586const recActs = () => $$('#recordings .r-panel.on .act.fx').filter(isShown);8788function recRefocus() {89 if (state.view !== 'recordings' || state.dock >= 0 || recUI.zone === 'list') return;90 if (!$('#recordings').contains(document.activeElement) || !isShown(document.activeElement)) recZone(recUI.zone);91}9293// armed is whether a delete-like action waits for its second press; ARMED94// says what the second does.95const armed = (act, it) => pendingDelete === `${act}:${it && it.id}`;96const ARMED = { delete: 'delete', skip: 'skip it', stop: 'stop it', end: 'stop recording', discard: 'delete it' };9798// upcomingChips says how an airing to come, or a recording under way,99// stands: recording until when, a conflict or a channel the tuner doesn't100// have (and why), part of a series.101function upcomingChips(it) {102 const series = it.ruleId && (state.dvr.rules || []).some((r) => r.id === it.ruleId && r.kind === 'series');103 const chips = [];104 if (it.status === 'recording') chips.push(`<span class="chip live">Recording until ${clock(Date.parse(it.until))}</span>`);105 if (it.status === 'conflict') chips.push('<span class="chip warn">Conflict</span>');106 if (it.status === 'unavailable') chips.push('<span class="chip">Channel not available</span>');107 if (series) chips.push('<span class="chip">Part of a series</span>');108 const why = it.status === 'conflict' || it.status === 'unavailable' || (it.status === 'recording' && it.detail) ? `<div class="r-why">${esc(it.detail || '')}</div>` : '';109 return chips.join('') + why;110}111112// upcomingActs are the actions of the selected airing to come: skip it; or113// of a recording under way: watch it from the start, record half an hour114// more, stop it (keeping what's recorded), or delete it.115function upcomingActs(it, act) {116 if (it.status !== 'recording') return act('skip', "Don't record", it);117 return act('play', 'Watch from the start', it) + act('extend', '30 more minutes', it) + act('end', 'Stop recording', it) + act('discard', 'Delete', it);118}119120function renderRecordings() {121 const v = $('#recordings');122 const lists = recLists();123 const tab = state.recTab || 'library';124 const sel = state.recSelBy || (state.recSelBy = { library: 0, upcoming: 0, series: 0 });125 for (const k of Object.keys(sel)) sel[k] = Math.min(sel[k], Math.max(0, lists[k].length - 1));126 const act = (name, label, it, cls = '') => `<button type="button" class="act fx${cls ? ` ${cls}` : ''}${armed(name, it) ? ' armed' : ''}" data-act="${name}">${armed(name, it) ? `${onRemote() ? 'OK' : 'Press'} again to ${ARMED[name]}` : label}</button>`;127128 f(v, 'menu').innerHTML = REC_TABS.map(([k, label]) => `<button type="button" class="rm-item fx ${k === tab ? 'on' : ''}" data-tab="${k}"><span class="rm-label">${label}</span><span class="rm-count">${lists[k].length}</span></button>`).join('');129 $$('.r-panel', v).forEach((p) => p.classList.toggle('on', p.dataset.panel === tab));130131 // Library: the selected recording above a grid of everything recorded.132 const lib = lists.library;133 const cur = lib[sel.library];134 f(v, 'detail').innerHTML = cur ? `135 <div>136 <div class="rd-kicker">${dayLabel(cur.start)} ${clock(cur.start)} | ${esc(cur.channel)} | ${Math.round((Date.parse(cur.end) - Date.parse(cur.start)) / MIN)} min${cur.sizeBytes ? ' | ' + sizeLabel(cur.sizeBytes) : ''}</div>137 <div class="rd-title">${esc(cur.title)}</div>138 <div class="rd-ep">${esc(cur.subtitle || '')}</div>139 <p class="rd-desc">${esc(cur.status === 'failed' ? cur.detail || 'This recording failed' : cur.description || '')}</p>140 ${cur.status !== 'failed' && cur.detail ? `<p class="rd-note">${esc(cur.detail)}</p>` : ''}141 <div class="rd-actions">${cur.status === 'failed' ? '' : act('play', resumable(cur) ? `Resume at ${mmss(cur.position)}` : 'Play', cur)}${resumable(cur) ? act('restart', 'From the start', cur) : ''}${cur.status === 'failed' ? '' : act('watched', cur.watched ? 'Mark unwatched' : 'Mark watched', cur)}${act('delete', 'Delete', cur)}</div>142 <div class="rd-hint">${recHint(cur)}</div>143 </div>144 <div class="rd-art" style="${cur.image ? `background-image:url('${esc(imageURL(cur.image))}')` : ''}"></div>`145 : '<div class="rd-empty">Nothing recorded yet. In the guide, OK on a program to come records it, or every episode.</div><div></div>';146 f(v, 'library').innerHTML = lib.map((it, i) => `147 <article class="r-card ${i === sel.library ? 'sel' : ''} ${it.status === 'failed' ? 'failed' : ''} ${it.watched ? 'watched' : ''}" data-i="${i}">148 <div class="r-art" style="${it.image ? `background-image:url('${esc(imageURL(it.image))}')` : ''}">${it.image ? '' : `<span>${esc(it.title)}</span>`}149 ${resumable(it) ? `<div class="r-prog"><i style="width:${Math.min(100, (it.position / it.duration) * 100)}%"></i></div>` : ''}150 ${it.watched ? '<b class="r-badge">Watched</b>' : ''}</div>151 <div class="r-meta">152 <div class="r-name">${esc(it.title)}</div>153 <div class="r-sub">${esc(it.subtitle || '')}</div>154 <div class="r-when">${dayLabel(it.start)} | ${esc(it.channel)}</div>155 </div>156 </article>`).join('');157158 f(v, 'upcoming').innerHTML = lists.upcoming.length ? lists.upcoming.map((it, i) => `159 <li class="r-item ${it.status} ${i === sel.upcoming ? 'sel' : ''}" data-i="${i}">160 <div class="r-time">${dayLabel(it.start)}<b>${clock(it.start)}</b></div>161 <div class="r-what"><div class="r-name">${esc(it.title)}</div><div class="r-sub">${esc(it.channel)} ${esc(it.callSign ? it.callSign.replace(/(DT|LD|CD|LP|CA|D)\d*$/, '') : '')}${it.subtitle ? ' | ' + esc(it.subtitle) : ''}</div>162 ${upcomingChips(it)}</div>163 <div class="r-acts">${i === sel.upcoming ? upcomingActs(it, act) : ''}</div>164 </li>`).join('') : '<li class="r-empty">Nothing scheduled.</li>';165166 f(v, 'rules').innerHTML = lists.series.length ? lists.series.map((r, i) => `167 <li class="r-item ${i === sel.series ? 'sel' : ''}" data-i="${i}">168 <div class="r-what"><div class="r-name">${esc(r.title)}</div><div class="r-sub">${esc(r.channel)} | ${r.newOnly ? 'New episodes' : 'Every episode'} | ${r.keep ? `Keep newest ${r.keep}` : 'Keep all'}</div></div>169 <div class="r-acts">${i === sel.series ? act('keep', r.keep ? `Keeps newest ${r.keep}` : 'Keeps all', r) + act('newonly', r.newOnly ? 'New episodes only' : 'Every episode', r) + act('stop', 'Stop series', r) : ''}</div>170 </li>`).join('') : '<li class="r-empty">No series recordings. In the guide, OK on a show to come, then Record series.</li>';171 if (lists.series.length && onKeys()) f(v, 'rules').insertAdjacentHTML('beforeend', '<li class="r-hint">K changes how many to keep | N new episodes only | Delete stops the series</li>');172 recRefocus();173}174175function recHint(cur) {176 if (onRemote()) return 'OK for the actions, Left for the other lists';177 return hints([['Enter', 'Enter', 'for the actions'], ...(resumable(cur) ? [['', 'Shift Enter', 'plays from the start']] : []), ['w', 'W', cur.watched ? 'unwatched' : 'watched'], ['', 'Delete', 'removes']]);178}179180function wireRecordings() {181 const v = $('#recordings');182 f(v, 'menu').addEventListener('click', (e) => {183 const b = e.target.closest('[data-tab]');184 if (!b) return;185 state.recTab = b.dataset.tab;186 renderRecordings();187 });188 v.addEventListener('click', (e) => {189 const b = e.target.closest('[data-act]');190 if (b) return recAct(b.dataset.act);191 const item = e.target.closest('[data-i]');192 if (item && state.recSelBy) {193 const tab = state.recTab || 'library';194 const was = state.recSelBy[tab];195 state.recSelBy[tab] = Number(item.dataset.i);196 renderRecordings();197 if (tab === 'library' && tappedAgain(was, state.recSelBy[tab])) {198 const it = recLists().library[was];199 if (it) playOnDemand(it, resumable(it) ? it.position : 0);200 }201 }202 return null;203 });204 v.addEventListener('focusin', (e) => {205 const b = e.target.closest('[data-act]');206 if (b) recUI.act = b.dataset.act;207 });208 f(v, 'library').addEventListener('dblclick', (e) => {209 const card = e.target.closest('[data-i]');210 if (card) {211 const it = recLists().library[Number(card.dataset.i)];212 playOnDemand(it, resumable(it) ? it.position : 0);213 }214 });215}216217// recAct does one of the selected item's actions.218function recAct(act) {219 const tab = state.recTab || 'library';220 const it = recLists()[tab][(state.recSelBy || {})[tab] || 0];221 if (!it) return null;222 const fail = (err) => toast(String(err && err.message ? err.message : err), 6000);223 switch (act) {224 case 'play': return playOnDemand(it, it.status === 'recording' ? 0 : resumable(it) ? it.position : 0);225 case 'extend': return api().ExtendRecording(it.id, 30).then((until) => {226 toast(`Recording ${it.title} until ${clock(Date.parse(until))}`);227 return loadDVR();228 }).catch(fail);229 case 'restart': return playOnDemand(it, 0);230 case 'watched': return api().MarkWatched(it.id, !it.watched).then(loadDVR).catch(fail);231 case 'keep': case 'newonly': {232 const steps = [0, 3, 5, 10];233 const u = act === 'keep' ? { keep: steps[(steps.indexOf(it.keep || 0) + 1) % steps.length], newOnly: !!it.newOnly } : { keep: it.keep || 0, newOnly: !it.newOnly };234 return api().UpdateRule(it.id, u).then(loadDVR).catch(fail);235 }236 case 'delete': case 'skip': case 'stop': case 'end': case 'discard': {237 if (!it.id) return null;238 if (!armed(act, it)) {239 pendingDelete = `${act}:${it.id}`;240 clearTimeout(pendingTimer);241 pendingTimer = setTimeout(() => { pendingDelete = ''; if (state.view === 'recordings') renderRecordings(); }, 4000);242 const what = act === 'delete' || act === 'discard' ? `delete ${it.title}` : act === 'stop' ? `stop recording ${it.title}` : act === 'end' ? `stop recording ${it.title}, keeping what's recorded` : `skip ${it.title}`;243 if (recUI.zone !== 'actions') toast(`Press Delete again to ${what}`);244 return renderRecordings();245 }246 pendingDelete = '';247 clearTimeout(pendingTimer);248 const done = act === 'stop' ? api().DeleteRule(it.id) : act === 'end' ? api().StopRecording(it.id) : api().DeleteRecording(it.id);249 return done.then(() => {250 toast(act === 'delete' || act === 'discard' ? 'Deleted' : act === 'stop' ? 'Series recording stopped' : act === 'end' ? `Stopped. ${it.title} is in the library` : 'Removed from the schedule');251 recZone('list');252 return loadDVR();253 }).catch(fail);254 }255 default: return null;256 }257}258259function recordingsKey(e) {260 const tab = state.recTab || 'library';261 const lists = recLists();262 const list = lists[tab];263 const sel = state.recSelBy || (state.recSelBy = { library: 0, upcoming: 0, series: 0 });264 if (e.key === '[' || e.key === ']') {265 const i = REC_TABS.findIndex(([k]) => k === tab);266 state.recTab = REC_TABS[(i + (e.key === ']' ? 1 : REC_TABS.length - 1)) % REC_TABS.length][0];267 return renderRecordings();268 }269 if (recUI.zone === 'menu') return recMenuKey(e);270 if (recUI.zone === 'actions' && recActionsKey(e)) return undefined;271 const cols = tab === 'library' ? Math.max(1, Math.round(f($('#recordings'), 'library').clientWidth / 280)) : 1;272 if (e.key === 'ArrowUp' && (!list.length || sel[tab] < cols)) { e.preventDefault(); return openDock(); }273 if (e.key === 'ArrowLeft' && (tab !== 'library' || sel[tab] % cols === 0)) { e.preventDefault(); return recZone('menu'); }274 if (!list.length) return undefined;275 switch (e.key) {276 case 'ArrowRight':277 if (tab === 'library') sel[tab] = Math.min(list.length - 1, sel[tab] + 1);278 else { e.preventDefault(); return recZone('actions', '-'); }279 break;280 case 'ArrowLeft': sel[tab] = Math.max(0, sel[tab] - 1); break;281 case 'ArrowDown': sel[tab] = Math.min(list.length - 1, sel[tab] + cols); break;282 case 'ArrowUp': sel[tab] = Math.max(0, sel[tab] - cols); break;283 case 'Enter':284 e.preventDefault();285 // Shift Enter, on a keyboard, plays a recording from its start.286 if (tab === 'library' && e.shiftKey) return playOnDemand(list[sel[tab]], 0);287 return recZone('actions', '-');288 case 'w': case 'W': return tab === 'library' ? recAct('watched') : undefined;289 case 'k': case 'K': return tab === 'series' ? recAct('keep') : undefined;290 case 'n': case 'N': return tab === 'series' ? recAct('newonly') : undefined;291 case 'Delete': case 'Backspace':292 e.preventDefault();293 return recAct(tab === 'library' ? 'delete' : tab === 'series' ? 'stop' : 'skip');294 default: return undefined;295 }296 e.preventDefault();297 renderRecordings();298 const el = $(`#recordings .r-panel.on [data-i="${sel[tab]}"]`);299 if (el) el.scrollIntoView({ block: 'nearest' });300 return undefined;301}302303// recMenuKey: Up and Down pick a list (as tabs do), Right or OK goes into304// it, Left (or Up from the first) to the dock.305function recMenuKey(e) {306 const i = REC_TABS.findIndex(([k]) => k === (state.recTab || 'library'));307 switch (e.key) {308 case 'ArrowUp': case 'ArrowDown': {309 const j = i + (e.key === 'ArrowUp' ? -1 : 1);310 if (j < 0) return openDock();311 if (j >= REC_TABS.length) break;312 state.recTab = REC_TABS[j][0];313 renderRecordings();314 break;315 }316 case 'ArrowRight': case 'Enter':317 if (recLists()[state.recTab || 'library'].length) recZone('list');318 break;319 case 'ArrowLeft': return openDock();320 default: return undefined;321 }322 e.preventDefault();323 return undefined;324}325326// recActionsKey: Left and Right along the actions, OK presses. Above the327// library's grid, Down goes back to it and Up to the dock; in a list Up328// and Down move to the item above or below, and Left from the first329// action back to the item.330function recActionsKey(e) {331 const acts = recActs();332 const el = document.activeElement;333 const lib = (state.recTab || 'library') === 'library';334 switch (e.key) {335 case 'ArrowLeft': case 'ArrowRight': {336 const next = neighbor(acts, el, e.key === 'ArrowLeft' ? -1 : 1);337 if (next) focusEl(next);338 else if (e.key === 'ArrowLeft' && !lib) recZone('list');339 break;340 }341 case 'ArrowUp': case 'ArrowDown':342 if (lib) {343 if (e.key === 'ArrowUp') openDock(); else recZone('list');344 break;345 }346 recZone('list');347 return false; // and moves in the list348 case 'Enter':349 if (acts.includes(el)) el.click(); else recZone('actions');350 break;351 default: return false;352 }353 e.preventDefault();354 return true;355}356357const resumable = (it) => !!it && !it.watched && it.position > 30 && it.duration > 0 && it.position < it.duration - 60;358