clone

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

Make the TV app work from a remote's arrows, OK and Back

FelineStateMachine committed d94340e
OK brings up on-screen controls over live TV: the banner, a timeline of
the program with the rewind window and live edge, and buttons for the
guide (first, so OK twice opens it), pause, skip, live, captions, audio,
record, favorite and last channel, with the views a press of Up above
them. They hide after 6 s, never while paused, and Back hides them first.
Left and Right skip and show the timeline. In the guide, OK watches
what's on or opens a later program's actions; holding OK opens actions
for any program. Recordings, Reception, Weather and Settings are all
reachable with the arrows, every view shares one amber focus ring, and
exactly one thing has it whenever anything is on screen. Text fields wait
on their row until OK, since an Android TV keyboard traps the arrows.
commit
d94340ee0dbb9682bf48c2a0065c6f62ee78bf23
parent
9f05269fb69e064a61e38abcf15e117843ffdc73
author
FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
kind
feat risk score 2.4 of 3, Jev's estimate
4 files changed, +1206 -206
MREADME.md16+-
Mfrontend/dist/app.js1222++++++++++++++++++++++++++----
Mfrontend/dist/index.html38+-
Mfrontend/dist/style.css136+++-
M README.md +11 -5
@@ -125,11 +125,12 @@ had.
125125 126126 | Key | TV |127127 | --- | --- |128+| Enter (a remote's OK) | On-screen controls: the banner, a timeline, and buttons for the guide (first, so Enter twice opens it), pause, skip, live, captions, audio, record, favorite and last channel, with the views above. Arrows move, Enter presses, Esc puts them away; they stay up while paused |128129 | Up / Down, PgUp / PgDn | Channel up / down |129130 | Digits then Enter | Tune a channel, e.g. `7` `.` `2` |130131 | L or Backspace | Previous channel |131132 | Space | Pause and resume (live TV keeps a 30 minute rewind window) |132-| Left / Right | Back 10 s / forward 30 s (Shift: 60 s) |133+| Left / Right | Back 10 s / forward 30 s (Shift: 60 s), with the controls up on the timeline |133134 | End | Jump back to live |134135 | R / Shift R / N | Record this show / every episode / new episodes |135136 | C | Closed captions on or off |
@@ -140,13 +141,18 @@ had.
140141 141142 | Key | Elsewhere |142143 | --- | --- |143-| G | Guide: arrows move, Enter watches, `[` `]` filters (Favorites, Sports...), Home jumps to now, R / Shift R / N record, f favorite, H hide (U undoes) |144-| D | Recordings: `[` `]` Library, Coming up, Series. Enter resumes, Shift Enter starts over, W marks watched, Delete twice removes; on a series K sets how many to keep, N new episodes only |145-| A | Reception: arrows pick a transmitter, `1` `2` `3` antenna. Preview any ZIP code in the left rail and copy a shareable summary |146-| W | Weather: current conditions, alerts, the next 24 hours, 7-day, radar loop and satellite |144+| G | Guide: arrows move, Up from the top row to the filters (Favorites, Sports...; also `[` `]`). Enter watches what's on, or shows a program to come's actions; hold Enter, or I, for any program's: watch, record, series, new episodes only, favorite, hide. Home jumps to now, R / Shift R / N record, f favorite, H hide (U undoes) |145+| D | Recordings: Left (or `[` `]`) for Library, Coming up, Series. Enter shows a recording's actions: resume, from the start, watched, delete (press twice); Shift Enter starts over, W marks watched, Delete twice removes; on a series K sets how many to keep, N new episodes only |146+| A | Reception: arrows pick a transmitter; Left for the antenna presets (or `1` `2` `3`) and to preview any ZIP code, then copy a shareable summary |147+| 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 |147148 | `,` or S | Settings: server, antenna, guide hours, deleting watched recordings, hidden channels |148149 | Esc | Back to TV; from a recording, back to live |149150 151+With only a remote's arrows, OK and Back (an Android TV remote), every view is152+reached from the views above it: Up from a view's top, or Left from its left153+edge, and on TV, Up through the on-screen controls. Whatever the arrows are on has154+an amber ring.155+150156 ## Data sources151157 152158 | Source | Used for | Cache |
M frontend/dist/app.js +1054 -168
@@ -277,12 +277,14 @@ function bootLog(msg, err) {
277277 278278 async function init() {279279 wireKeys();280+ wireOSD();280281 wireDock();281282 wireMouse();282283 wireGuide();283284 wireAntenna();284285 wireSettings();285286 wireRecordings();287+ wireWeather();286288 wirePads();287289 wireMediaSession();288290 renderHints();
@@ -343,7 +345,9 @@ function showConnect(err) {
343345 // In a browser the server is the page's own (web.js): only a token to give.344346 const fixed = !!(state.boot && state.boot.fixedServer);345347 form.elements.server.readOnly = fixed;346- form.elements[fixed ? 'token' : 'server'].focus();348+ form.elements.server.parentElement.classList.toggle('fx', !fixed);349+ bootField(form.elements[fixed ? 'token' : 'server'].parentElement);350+ f($('#boot'), 'hint').innerHTML = hints([['Arrows', 'Arrows', 'move'], ['Enter', 'Enter', androidTV ? 'types, or connects' : 'connects']]);347351 form.onsubmit = async (e) => {348352 e.preventDefault();349353 const server = form.elements.server.value.trim();
@@ -403,17 +407,23 @@ function tick() {
403407 404408 // ---------- views ----------405409 function setView(v) {406- if (state.dock >= 0) closeDock();407- if (v === state.view) return;410+ if (state.dock >= 0) closeDock(false);411+ if (v === state.view) return restoreFocus();408412 const prev = state.view;413+ // The banner and the controls go with TV (their timers with them).414+ if (prev === 'tv') hideBanner();415+ // The focus starts over in the new view.416+ blurIn(document.body);409417 state.view = v;410418 document.body.classList.remove(`mode-${prev}`);411419 document.body.classList.add(`mode-${v}`);412420 $$('#dock button').forEach((b) => b.classList.toggle('on', b.dataset.view === v));413421 if (v === 'guide') { openGuide(); loadDVR(); }414- if (v === 'recordings') { renderRecordings(); loadDVR(); }422+ if (v === 'recordings') { openRecordings(); loadDVR(); }415423 if (v === 'weather') {416424 renderWeather();425+ wxUI.at = 0;426+ wxFocus();417427 loadWeather();418428 if (state.info.weatherStar && !(state.current && state.current.weather)) {419429 state.wxPreview = true;
@@ -426,8 +436,9 @@ function setView(v) {
426436 video.muted = !!state.userMuted;427437 if (!(state.current && state.current.weather)) showWX(false);428438 }429- if (v === 'antenna') renderAntenna();439+ if (v === 'antenna') openAntenna();430440 if (v === 'settings') openSettings();441+ return undefined;431442 }432443 433444 function wireDock() {
@@ -445,7 +456,9 @@ function wireMouse() {
445456 // WebKitGTK focuses a button when it is clicked, and Enter or Space then446457 // pressed it again besides doing what the key does here. Clicks leave447458 // the focus where it was, as on a Mac.448- document.addEventListener('mousedown', (e) => { if (e.target.closest('button')) e.preventDefault(); });459+ document.addEventListener('mousedown', (e) => {460+ if (!e.target.closest(TEXT_INPUT) && e.target.closest('button, .fx')) e.preventDefault();461+ });449462 // Only a pointer that really moved counts. WebKit also sends moves (and450463 // mouseenter) when content appears under a pointer left still, as on a451464 // TV where it rests wherever it was; the banner then never hid.
@@ -454,13 +467,8 @@ function wireMouse() {
454467 document.body.classList.add('mouse-active');455468 clearTimeout(mouseTimer);456469 mouseTimer = setTimeout(() => document.body.classList.remove('mouse-active'), 2500);457- if (state.view === 'tv' && e.clientY > window.innerHeight - 140) showBanner();458- });459- $('#banner').addEventListener('mouseenter', () => {460- if (document.body.classList.contains('mouse-active')) clearTimeout(state.bannerTimer);461- });462- $('#banner').addEventListener('mouseleave', () => {463- if (document.body.classList.contains('mouse-active')) hideBannerSoon();470+ // The controls, for a pointer near the bottom; no focus mark.471+ if (state.view === 'tv' && e.clientY > window.innerHeight * 0.55) openOSD(null);464472 });465473 }466474
@@ -478,7 +486,6 @@ async function tune(ch, { quiet = false } = {}) {
478486 state.current = ch;479487 state.note = '';480488 state.recording = null;481- video.controls = false;482489 const token = ++state.tuneToken;483490 if (!quiet) showBanner(); else renderBanner();484491 stage.classList.add('tuning');
@@ -615,6 +622,7 @@ function episodeLine(p) {
615622 function renderBanner() {616623 updateMediaSession();617624 showTimeshift();625+ renderOSD();618626 const ch = state.current;619627 const b = $('#banner');620628 if (state.recording) return renderRecordingBanner(b, state.recording);
@@ -689,34 +697,353 @@ function showChannelLogo(b, ch) {
689697 }690698 691699 // The banner shows briefly on a channel change and on request. Pressing I692-// cycles: banner, banner with reception details, hidden.700+// cycles: banner, banner with reception details, hidden. With the701+// on-screen controls up it stays as long as they do.693702 function showBanner(detail = false) {694703 renderBanner();695704 const b = $('#banner');696705 b.classList.toggle('detail', detail);697706 b.classList.add('show');698- hideBannerSoon(detail ? 12000 : 4000);707+ if (osd.open) return osdTouch();708+ return hideBannerSoon(detail ? 12000 : 4000);699709 }700710 711+// hideBanner puts the banner away, and the controls with it.701712 function hideBanner() {702713 clearTimeout(state.bannerTimer);714+ closeOSD();703715 $('#banner').classList.remove('show', 'detail');704716 }705717 706718 function hideBannerSoon(ms = 4000) {707719 clearTimeout(state.bannerTimer);720+ if (osd.open) return osdTouch();708721 state.bannerTimer = setTimeout(hideBanner, ms);722+ return undefined;709723 }710724 711725 // cycleBanner is the info key. It also flashes where the picture is, in712-// the playback badge.726+// the playback badge. With the controls up it shows or hides reception.713727 function cycleBanner() {714728 const b = $('#banner');729+ if (osd.open) {730+ if (!state.recording) b.classList.toggle('detail');731+ return flashPlace();732+ }715733 if (b.classList.contains('show') && (b.classList.contains('detail') || state.recording)) return hideBanner();716734 flashPlace();717735 return showBanner(b.classList.contains('show'));718736 }719737 738+// ---------- on-screen controls ----------739+// OK on live TV brings up the controls over the picture, as video players740+// on a TV do (Media3's PlayerControlView, Leanback, Kodi's OSD, the Live741+// TV app's menu): the channel banner, a timeline of the program and of what742+// can be rewound, a row of labeled buttons, and the dock above as the way743+// to every view. The focus starts on Guide, so OK twice opens the guide;744+// Left and Right on live TV skip and bring them up with the timeline745+// picked. Up and Down move between the rows, Left and Right along one, OK746+// presses. They go after OSD_MS without a key, but stay while paused (as747+// Media3's and Leanback's do); Back puts them away.748+const OSD_MS = 6000;749+const osd = { open: false, timer: 0, tick: 0, last: 'guide', lastButton: 'guide', hover: false };750+751+// The buttons, in order: [action, label]. Labels change with the state.752+const OSD_BUTTONS = [753+ ['guide', 'Guide'], ['play', 'Pause'], ['back', '-10 s'], ['ahead', '+30 s'], ['live', 'Live'],754+ ['captions', 'Captions'], ['audio', 'Audio'], ['record', 'Record'], ['favorite', 'Favorite'], ['last', 'Last channel'],755+];756+const OSD_KEYS = { guide: 'G', play: 'Space', back: 'Left', ahead: 'Right', live: 'End', captions: 'C', audio: 'V', record: 'R', favorite: 'F', last: 'L' };757+const ICON_TEXT = 'text-anchor="middle" font-family="Plex Mono, Menlo, monospace" font-weight="700"';758+const OSD_ICONS = {759+ guide: '<path d="M3 4h18v3.5H3zm0 6.25h7.5v3.5H3zm9.5 0H21v3.5h-8.5zM3 16.5h11V20H3zm13 0h5V20h-5z"/>',760+ play: '<path d="M7 4.5v15L19.5 12z"/>',761+ pause: '<path d="M6 4.5h4.2v15H6zm7.8 0H18v15h-4.2z"/>',762+ back: `<path d="M12 5.5a7.5 7.5 0 1 1-7.5 7.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M12 1.5v8l-5-4z"/><text x="12.4" y="16.2" font-size="7.2" ${ICON_TEXT}>10</text>`,763+ ahead: `<path d="M12 5.5a7.5 7.5 0 1 0 7.5 7.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M12 1.5v8l5-4z"/><text x="11.6" y="16.2" font-size="7.2" ${ICON_TEXT}>30</text>`,764+ live: '<circle cx="12" cy="12" r="4.2"/><path d="M6.6 6.6a7.6 7.6 0 0 0 0 10.8M17.4 6.6a7.6 7.6 0 0 1 0 10.8" fill="none" stroke="currentColor" stroke-width="2"/>',765+ tv: '<rect x="3" y="6.5" width="18" height="12.5" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 2.5l4 3.6 4-3.6" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12.75" r="2.4"/>',766+ captions: `<rect x="2.5" y="5" width="19" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="2"/><text x="12" y="15.1" font-size="7.6" ${ICON_TEXT}>CC</text>`,767+ audio: '<path d="M3 9h4l5-4.5v15L7 15H3z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.2 5.8a8.8 8.8 0 0 1 0 12.4" fill="none" stroke="currentColor" stroke-width="2"/>',768+ record: '<circle cx="12" cy="12" r="7"/>',769+ favorite: '<path d="M12 3.6l2.5 5.4 5.9.6-4.4 4 1.3 5.8L12 16.4l-5.3 3 1.3-5.8-4.4-4 5.9-.6z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>',770+ favorited: '<path d="M12 3.6l2.5 5.4 5.9.6-4.4 4 1.3 5.8L12 16.4l-5.3 3 1.3-5.8-4.4-4 5.9-.6z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>',771+ last: '<path d="M8 3.5 3 8l5 4.5V9.3h9V6.7H8zM16 11.5v3.2H7v2.6h9v3.2l5-4.5z"/>',772+};773+774+const timelineEl = () => f($('#banner'), 'timeline');775+const osdButtons = () => $$('#banner .osd-bar .ob').filter((b) => !b.hidden);776+777+function wireOSD() {778+ const b = $('#banner');779+ f(b, 'osd').innerHTML = OSD_BUTTONS.map(([act, label]) => `<button type="button" class="ob fx" data-act="${act}" title="${esc(label)} (${OSD_KEYS[act]})"><svg viewBox="0 0 24 24" aria-hidden="true"></svg><span>${esc(label)}</span></button>`).join('');780+ f(b, 'osd').addEventListener('click', (e) => {781+ const btn = e.target.closest('[data-act]');782+ if (!btn) return;783+ osdTouch();784+ osdAct(btn.dataset.act);785+ });786+ b.addEventListener('focusin', (e) => {787+ const btn = e.target.closest('.ob');788+ if (btn) osd.last = osd.lastButton = btn.dataset.act;789+ else if (e.target === timelineEl()) osd.last = 'timeline';790+ });791+ // The pointer on the controls keeps them up.792+ b.addEventListener('mouseenter', () => { osd.hover = document.body.classList.contains('mouse-active'); osdTouch(); });793+ b.addEventListener('mouseleave', () => { osd.hover = false; osdTouch(); });794+ // A click on the timeline goes there.795+ timelineEl().addEventListener('click', (e) => {796+ const bar = $('.tl-bar', timelineEl()).getBoundingClientRect();797+ if (bar.width > 0) seekTimeline((e.clientX - bar.left) / bar.width);798+ });799+}800+801+// openOSD brings up the controls, with the focus on focus ("guide", another802+// button's action, or "timeline"), or on none for a pointer.803+function openOSD(focus = 'guide') {804+ if (state.view !== 'tv' || (!state.current && !state.recording)) return;805+ if (!osd.open) {806+ osd.open = true;807+ document.body.classList.add('osd-open');808+ clearTimeout(state.bannerTimer);809+ osd.tick = setInterval(renderTimeline, 1000);810+ }811+ renderBanner();812+ $('#banner').classList.add('show', 'osd');813+ if (focus) osdFocus(focus);814+ osdTouch();815+}816+817+function closeOSD() {818+ if (!osd.open) return;819+ osd.open = false;820+ clearTimeout(osd.timer);821+ clearInterval(osd.tick);822+ osd.tick = 0;823+ osd.hover = false;824+ document.body.classList.remove('osd-open');825+ const b = $('#banner');826+ b.classList.remove('osd', 'tl-on');827+ if (state.dock >= 0 && state.view === 'tv') closeDock(false);828+ blurIn(b);829+}830+831+// osdTouch starts the controls' time over: a key was pressed.832+function osdTouch() {833+ clearTimeout(osd.timer);834+ if (!osd.open || osd.hover || pausedStream()) return;835+ osd.timer = setTimeout(hideBanner, OSD_MS);836+}837+838+const hasPicture = () => !!(video.src || state.hls) && !video.hidden && $('#nosignal').hidden;839+const pausedStream = () => hasPicture() && video.paused;840+841+// osdFocus focuses a button by its action, or the timeline: the nearest842+// there is when that one isn't showing.843+function osdFocus(target) {844+ const tl = timelineEl();845+ if (target === 'timeline' && !tl.hidden) return focusEl(tl);846+ const buttons = osdButtons();847+ const want = target === 'timeline' ? osd.lastButton : target;848+ return focusEl(buttons.find((b) => b.dataset.act === want) || buttons.find((b) => b.dataset.act === 'play') || buttons[0]);849+}850+851+// osdKey handles a key while the controls are up; it reports whether it852+// did. Keys it leaves (letters, Space, channel up and down) do what they853+// do on TV.854+function osdKey(e) {855+ const k = e.key;856+ const el = document.activeElement;857+ const onTl = el === timelineEl();858+ const btn = el && el.closest ? el.closest('#banner .ob') : null;859+ if ((k === 'ArrowLeft' || k === 'ArrowRight') && e.shiftKey) {860+ e.preventDefault();861+ skip(k === 'ArrowLeft' ? -60 : 60);862+ renderTimeline();863+ return true;864+ }865+ if (k === 'Escape') {866+ // A channel number being typed goes first.867+ e.preventDefault();868+ if (state.entry) cancelEntry(); else hideBanner();869+ return true;870+ }871+ if (!onTl && !btn) {872+ // Up by a pointer, or the focus went: the first key finds it.873+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(k)) return false;874+ e.preventDefault();875+ osdFocus(osd.last);876+ return true;877+ }878+ switch (k) {879+ case 'ArrowLeft': case 'ArrowRight':880+ if (onTl) {881+ skip(k === 'ArrowLeft' ? -10 : 30);882+ renderTimeline();883+ } else {884+ focusEl(neighbor(osdButtons(), btn, k === 'ArrowLeft' ? -1 : 1));885+ }886+ break;887+ case 'ArrowUp':888+ if (btn && !timelineEl().hidden) focusEl(timelineEl());889+ else openDock();890+ break;891+ case 'ArrowDown':892+ if (onTl) osdFocus(osd.lastButton);893+ break;894+ case 'Enter':895+ if (onTl) togglePause(); else btn.click();896+ break;897+ default: return false;898+ }899+ e.preventDefault();900+ return true;901+}902+903+function osdAct(act) {904+ const after = (x) => Promise.resolve(x).then(renderOSD);905+ switch (act) {906+ case 'guide': return setView('guide');907+ case 'play': return togglePause();908+ case 'back': skip(-10); return renderTimeline();909+ case 'ahead': skip(30); return renderTimeline();910+ case 'live': return state.recording ? state.current && tune(state.current) : goLive();911+ case 'captions': return after(toggleCaptions());912+ case 'audio': return after(cycleAudio());913+ case 'record': return after(recordNow('once'));914+ case 'favorite': return after(toggleFavorite(state.current));915+ case 'last': return state.previous && tune(state.previous);916+ default: return null;917+ }918+}919+920+// renderOSD shows the buttons that apply now, labeled for the state, and921+// the timeline. The focus moves on from a button that went.922+function renderOSD() {923+ if (!osd.open) return;924+ const b = $('#banner');925+ const ch = state.current;926+ const rec = state.recording;927+ const now = Date.now();928+ // Video controls for a stream, not the weather channel's display or a929+ // channel that failed to tune.930+ const stream = $('#nosignal').hidden && !!(rec || (ch && !ch.weather));931+ const p = !rec && ch && !ch.own ? airingAt(ch, now) : null;932+ const it = p && state.dvrKeys.get(airingKey(ch, p));933+ const fav = !rec && isFav(ch);934+ const was = document.activeElement;935+ const before = osdButtons();936+ const at = before.indexOf(was);937+ const set = (act, on, label, icon = act, lit = false) => {938+ const btn = $(`.ob[data-act="${act}"]`, b);939+ btn.hidden = !on;940+ if (!on) return;941+ const span = btn.lastElementChild;942+ if (span.textContent !== label) span.textContent = label;943+ if (btn.dataset.icon !== icon) {944+ btn.dataset.icon = icon;945+ btn.firstElementChild.innerHTML = OSD_ICONS[icon];946+ }947+ btn.classList.toggle('on', lit);948+ if (act !== 'guide' && act !== 'last') btn.setAttribute('aria-pressed', String(lit));949+ };950+ set('guide', true, 'Guide');951+ set('play', stream, video.paused && hasPicture() ? 'Play' : 'Pause', video.paused && hasPicture() ? 'play' : 'pause');952+ set('back', stream, '-10 s');953+ set('ahead', stream, '+30 s');954+ set('live', !!rec || stream, rec ? 'Live TV' : 'Live', rec ? 'tv' : 'live');955+ set('captions', stream, 'Captions', 'captions', stream && captionsWanted());956+ set('audio', stream && audioTracks().length > 1, 'Audio');957+ // Recording is for the antenna's channels, on a server with a DVR.958+ set('record', !!(state.info.dvr && p && p._e > now), it ? (it.status === 'recording' ? 'Recording' : 'Will record') : 'Record', 'record', !!it);959+ set('favorite', !rec && !!ch, 'Favorite', fav ? 'favorited' : 'favorite', fav);960+ set('last', !rec && !!state.previous, 'Last channel');961+ const last = $('.ob[data-act="last"]', b);962+ last.title = state.previous ? `Back to ${state.previous.number} ${displayCall(state.previous)} (L)` : '';963+ renderTimeline();964+ if (was && b.contains(was) && (was.hidden || document.activeElement !== was)) {965+ const after = osdButtons();966+ if (was === timelineEl() || at < 0) osdFocus(osd.lastButton);967+ else focusEl(after[Math.min(at, after.length - 1)]);968+ }969+}970+971+// timelineModel is what the timeline shows, in one scale: for live TV, ms972+// since the epoch, from the program's start (or whatever can be rewound973+// before it) to its end; for a recording, seconds into it.974+function timelineModel() {975+ if (!hasPicture()) return null;976+ const r = video.seekable;977+ const has = r && r.length > 0;978+ if (state.recording) {979+ const off = state.recOffset || 0;980+ const pos = off + (video.currentTime || 0);981+ const dur = Math.max(state.recording.duration || 0, pos);982+ if (!dur) return null;983+ return {984+ lo: 0, hi: dur, past: 0, edge: null, head: pos, bufLo: has ? off + r.start(0) : pos, bufHi: has ? off + r.end(r.length - 1) : pos,985+ left: mmss(pos), right: mmss(dur), at: mmss(pos), live: false,986+ };987+ }988+ const now = Date.now();989+ const behind = behindLive();990+ const head = now - behind * 1000;991+ const bufLo = has ? now - Math.max(0, livePoint() - r.start(0)) * 1000 : head;992+ const p = airingAt(state.current, now);993+ const lo = Math.min(p ? p._s : now - 15 * MIN, bufLo, head);994+ const hi = p ? p._e : now;995+ const live = behind <= 4;996+ return { lo, hi, past: now, edge: now, head, bufLo, bufHi: now, left: clock(lo), right: p ? clock(hi) : 'Now', at: live ? 'Live' : `-${mmss(behind)}`, live };997+}998+999+function renderTimeline() {1000+ if (!osd.open) return;1001+ const b = $('#banner');1002+ const tl = timelineEl();1003+ const m = timelineModel();1004+ const show = !!m;1005+ if (tl.hidden === show) {1006+ // The timeline goes (a failed tune, the weather channel): its focus1007+ // moves down to the buttons.1008+ const had = document.activeElement === tl;1009+ tl.hidden = !show;1010+ if (had && !show) osdFocus(osd.lastButton);1011+ }1012+ b.classList.toggle('tl-on', show);1013+ const liveBtn = $('.ob[data-act="live"]', b);1014+ if (liveBtn) liveBtn.classList.toggle('at', !!(m && m.live));1015+ if (!m) return;1016+ const span = m.hi - m.lo || 1;1017+ const pct = (t) => `${Math.max(0, Math.min(100, ((t - m.lo) / span) * 100)).toFixed(2)}%`;1018+ const bar = $('.tl-bar', tl);1019+ const [past, buf, edge, head] = bar.children;1020+ past.style.width = pct(m.past);1021+ buf.style.left = pct(m.bufLo);1022+ buf.style.width = `calc(${pct(m.bufHi)} - ${pct(m.bufLo)})`;1023+ edge.hidden = m.edge == null;1024+ if (m.edge != null) edge.style.left = pct(m.edge);1025+ head.style.left = pct(m.head);1026+ f(tl, 'tl-lo').textContent = m.left;1027+ f(tl, 'tl-hi').textContent = m.right;1028+ f(tl, 'tl-at').textContent = m.at;1029+ tl.setAttribute('aria-valuetext', m.at);1030+}1031+1032+// seekTimeline goes to a fraction of the timeline (a click on it).1033+function seekTimeline(frac) {1034+ const m = timelineModel();1035+ const r = video.seekable;1036+ if (!m || !r || !r.length) return;1037+ const t = m.lo + Math.max(0, Math.min(1, frac)) * (m.hi - m.lo);1038+ // Both scales count forward with the picture: ms for live, s for a recording.1039+ const delta = state.recording ? t - m.head : (t - m.head) / 1000;1040+ const from = video.currentTime;1041+ video.currentTime = Math.min(Math.max(from + delta, r.start(0)), state.recording ? r.end(r.length - 1) : livePoint());1042+ showTimeshift();1043+ renderTimeline();1044+ osdTouch();1045+}1046+7201047 const presetLabel = (name) => (state.presets.find((p) => p.name === name) || { label: name }).label;7211048 7221049 // ---------- number entry ----------
@@ -768,29 +1095,49 @@ function pressKey(key, { shiftKey = false, repeat = false } = {}) {
7681095 (document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey, repeat, bubbles: true, cancelable: true }));7691096 }7701097 1098+// Keys held down: the Android TV app's WebView sends a held key again1099+// without marking it a repeat, so another keydown before its keyup is one.1100+const held = new Map(); // key: when its last keydown came1101+const HELD_MS = 700;1102+7711103 function wireKeys() {7721104 document.addEventListener('keydown', (e) => {773- if (e.isTrusted) setPrompts(false);1105+ let again = e.repeat;1106+ if (e.isTrusted) {1107+ setPrompts(false);1108+ const at = held.get(e.key);1109+ again = again || (at !== undefined && e.timeStamp - at < HELD_MS);1110+ held.set(e.key, e.timeStamp);1111+ }7741112 const alias = KEY_ALIASES[e.key];7751113 if (alias) {7761114 e.preventDefault();777- return pressKey(alias[0], { shiftKey: !!alias[1] });1115+ return pressKey(alias[0], { shiftKey: !!alias[1], repeat: again });7781116 }7791117 const typing = e.target.matches && e.target.matches(TEXT_INPUT);1118+ if (document.body.classList.contains('booting')) return bootKey(e, typing);7801119 if (typing && e.key !== 'Escape') return;781- if (typing && document.body.classList.contains('booting')) return;7821120 if (e.metaKey || e.ctrlKey || e.altKey) return;7831121 const k = e.key;784-785- if (document.body.classList.contains('booting')) {786- if (!typing && (k === 'r' || k === 'R')) location.reload();1122+ // A held OK, or Space, acts once; held, OK is a long press.1123+ if ((k === 'Enter' || k === ' ') && again) {1124+ e.preventDefault();1125+ if (k === 'Enter') holdLong();7871126 return;7881127 }1128+ if (osd.open) osdTouch();7891129 if (state.dock >= 0 && dockKey(e)) return;790- if (state.view === 'settings' && !typing && settingsKey(e)) return;1130+ if (typing) {1131+ // Esc puts a field away, back to its row.1132+ e.preventDefault();1133+ e.target.blur();1134+ return restoreFocus();1135+ }1136+ if (state.view === 'settings' && settingsKey(e)) return;1137+ if (state.view === 'tv' && osd.open && osdKey(e)) return;7911138 if (k === 'Escape') {7921139 if (state.entry) return cancelEntry();793- if (typing) e.target.blur();1140+ if (viewBack()) return;7941141 if (state.view !== 'tv') return setView('tv');7951142 if (state.recording && state.current) return tune(state.current);7961143 return hideBanner();
@@ -818,24 +1165,20 @@ function wireKeys() {
8181165 if (state.view === 'guide') return guideKey(e);8191166 if (state.view === 'antenna') return antennaKey(e);8201167 if (state.view === 'recordings') return recordingsKey(e);821- if (state.view !== 'tv') {822- // Weather: Up and Down scroll, Up at the top and Left move to the823- // dock. (Settings has its own keys, settingsKey.)824- const main = $(`#${state.view} .main`);825- if (k === 'ArrowLeft' || (k === 'ArrowUp' && (!main || main.scrollTop <= 0))) { e.preventDefault(); return openDock(); }826- if (main && (k === 'ArrowUp' || k === 'ArrowDown')) { e.preventDefault(); main.scrollTop += k === 'ArrowDown' ? 240 : -240; }827- return;828- }1168+ if (state.view === 'weather') return weatherKey(e);1169+ if (state.view !== 'tv') return;8291170 8301171 if (/^[0-9]$/.test(k) || (k === '.' && state.entry)) { e.preventDefault(); return entryKey(k); }8311172 switch (k) {8321173 case 'ArrowUp': case 'PageUp': e.preventDefault(); return step(-1);8331174 case 'ArrowDown': case 'PageDown': e.preventDefault(); return step(1);834- case 'ArrowLeft': e.preventDefault(); return skip(e.shiftKey ? -60 : -10);835- case 'ArrowRight': e.preventDefault(); return skip(e.shiftKey ? 60 : 30);1175+ // Left and Right skip, and bring up the controls with the timeline1176+ // picked, for more of the same.1177+ case 'ArrowLeft': e.preventDefault(); skip(e.shiftKey ? -60 : -10); return openOSD('timeline');1178+ case 'ArrowRight': e.preventDefault(); skip(e.shiftKey ? 60 : 30); return openOSD('timeline');8361179 case ' ': e.preventDefault(); return togglePause();8371180 case 'End': e.preventDefault(); return goLive();838- case 'Enter': return state.entry ? commitEntry() : setView('guide');1181+ case 'Enter': e.preventDefault(); return state.entry ? commitEntry() : openOSD();8391182 case 'i': case 'I': return cycleBanner();8401183 case 'l': case 'L': case 'Backspace': return state.previous && tune(state.previous);8411184 case 'r': return recordNow('once');
@@ -847,6 +1190,103 @@ function wireKeys() {
8471190 default:8481191 }8491192 });1193+ document.addEventListener('keyup', (e) => {1194+ held.delete(e.key);1195+ holdEnd(e);1196+ });1197+ window.addEventListener('blur', () => held.clear());1198+}1199+1200+// ---------- holding OK ----------1201+// Holding OK (or Enter) for more, as on Android TV and in Kodi, where a long1202+// press brings up a context menu: in the guide, a program's actions. A1203+// press acts on its keyup; a hold once the key comes again (Android sends1204+// a held key again at its long-press timeout, a keyboard repeats) or it's1205+// been down HOLD_MS. A controller's button (pressKey) has no keyup, and1206+// acts at once.1207+const HOLD_MS = 500;1208+const hold = { timer: 0, short: null, long: null };1209+1210+function holdStart(short, long) {1211+ clearTimeout(hold.timer);1212+ hold.short = short;1213+ hold.long = long;1214+ hold.timer = setTimeout(holdLong, HOLD_MS);1215+}1216+1217+function holdLong() {1218+ if (!hold.timer) return;1219+ clearTimeout(hold.timer);1220+ hold.timer = 0;1221+ const long = hold.long;1222+ hold.short = null;1223+ hold.long = null;1224+ if (long) long();1225+}1226+1227+function holdEnd(e) {1228+ if (e.key !== 'Enter' || !hold.timer) return;1229+ clearTimeout(hold.timer);1230+ hold.timer = 0;1231+ const short = hold.short;1232+ hold.short = null;1233+ hold.long = null;1234+ if (short) short();1235+}1236+1237+// bootKey is a key while starting: on the connect prompt the arrows move1238+// between its fields and Connect (Up and Down only, while typing), and OK1239+// on a field puts the cursor in it.1240+function bootKey(e, typing) {1241+ const form = $('.boot-connect');1242+ if (form.hidden) {1243+ if (!typing && (e.key === 'r' || e.key === 'R')) location.reload();1244+ return;1245+ }1246+ const items = $$('.fx', form);1247+ const el = document.activeElement;1248+ const i = items.indexOf(el && el.closest ? el.closest('.fx') : null);1249+ if (e.key === 'Enter' && el && el.matches('.boot-field')) {1250+ e.preventDefault();1251+ const input = $('input', el);1252+ input.focus();1253+ input.select();1254+ return;1255+ }1256+ const dir = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key];1257+ if (!dir || (typing && (e.key === 'ArrowLeft' || e.key === 'ArrowRight'))) return;1258+ e.preventDefault();1259+ bootField(items[i < 0 ? 0 : Math.max(0, Math.min(items.length - 1, i + dir))]);1260+}1261+1262+// bootField moves to a field of the connect prompt: on a TV to its row, as1263+// a field with the cursor in it would bring up the keyboard at the next1264+// key; elsewhere into it, to type.1265+function bootField(el) {1266+ if (!el) return;1267+ const input = el.matches('.boot-field') && !androidTV ? $('input', el) : null;1268+ if (input) { input.focus(); input.select(); } else focusEl(el);1269+}1270+1271+// On the Android TV app the keyboard comes up over the page (the visual1272+// viewport shrinks); put away with Back, it leaves its field with the1273+// cursor, where the next arrow would bring it up again. So the field lets1274+// go then, back to its row, as Esc does: a setting's text goes back to1275+// what it was (Enter, the keyboard's own key, keeps it).1276+const ime = { open: false };1277+if (androidTV && window.visualViewport) {1278+ visualViewport.addEventListener('resize', () => {1279+ const el = document.activeElement;1280+ const typing = !!(el && el.matches && el.matches(TEXT_INPUT));1281+ if (visualViewport.height < innerHeight * 0.9) { ime.open = typing; return; }1282+ if (!ime.open) return;1283+ ime.open = false;1284+ if (!typing) return;1285+ if (el.dataset.was !== undefined) el.value = el.dataset.was;1286+ const row = el.closest('.fx');1287+ el.blur();1288+ if (row) focusEl(row); else restoreFocus();1289+ });8501290 }8511291 8521292 // airwavesBack is the Android TV app's Back key: it does what Esc does and
@@ -856,7 +1296,7 @@ window.airwavesBack = () => {
8561296 if (!state.settings || document.body.classList.contains('booting')) return false;8571297 const el = document.activeElement;8581298 const typing = !!(el && el.matches && el.matches(TEXT_INPUT));859- const open = state.dock >= 0 || typing || !!state.entry || state.view !== 'tv'1299+ const open = state.dock >= 0 || typing || !!state.entry || state.view !== 'tv' || osd.open8601300 || !!(state.recording && state.current) || $('#banner').classList.contains('show');8611301 if (!open) return false;8621302 pressKey('Escape');
@@ -884,19 +1324,22 @@ document.addEventListener('visibilitychange', () => {
8841324 8851325 // ---------- dock ----------8861326 // Without letter keys (a TV remote, a controller) the dock leads to every887-// view: Left from its left edge, or Up from the top of a view whose rows888-// don't wrap (the guide's do), moves to it;889-// Left and Right pick, Enter opens, Esc or Down goes back.1327+// view: Left from its left edge, or Up from the top of a view, moves to1328+// it, and on TV it's the row above the on-screen controls. Left and Right1329+// pick, Enter opens, Esc or Down goes back to where the focus was.8901330 const dockButtons = () => $$('#dock button').filter((b) => b.offsetParent);8911331 8921332 function openDock() {1333+ const el = document.activeElement;1334+ if (el && el !== document.body && !el.matches(TEXT_INPUT)) el.blur();8931335 state.dock = Math.max(0, dockButtons().findIndex((b) => b.dataset.view === state.view));8941336 renderDock();8951337 }8961338 897-function closeDock() {1339+function closeDock(restore = true) {8981340 state.dock = -1;8991341 renderDock();1342+ if (restore) restoreFocus();9001343 }9011344 9021345 function renderDock() {
@@ -913,7 +1356,7 @@ function dockKey(e) {
9131356 case 'ArrowUp': break;9141357 case 'Enter': case ' ': {9151358 const b = buttons[state.dock];916- closeDock();1359+ closeDock(false);9171360 if (b) setView(b.dataset.view);9181361 e.preventDefault();9191362 return true;
@@ -926,6 +1369,55 @@ function dockKey(e) {
9261369 return true;9271370 }9281371 1372+// ---------- focus ----------1373+// Where the remote, a controller or the arrow keys are is always marked,1374+// the same way in every view (style.css, Focus). Buttons, fields and the1375+// weather's parts take the page's focus (.fx); the guide's grid and the1376+// lists of Recordings, Reception and Settings mark their pick with a class,1377+// while that part of the view has the focus (its data-zone). Only keys1378+// focus: a click leaves the focus where it was (wireMouse).1379+const isShown = (el) => !!el && !el.hidden && el.getClientRects().length > 0;1380+1381+function focusEl(el, scroll = true) {1382+ if (!el) return false;1383+ if (document.activeElement !== el) el.focus({ preventScroll: true });1384+ if (scroll && el.closest('.wx-main, .r-grid, .r-list, .s-list, .a-tablewrap')) el.scrollIntoView({ block: 'nearest' });1385+ return document.activeElement === el;1386+}1387+1388+// blurIn takes the focus off whatever has it inside root.1389+function blurIn(root) {1390+ const el = document.activeElement;1391+ if (el && el !== document.body && root.contains(el)) el.blur();1392+}1393+1394+// neighbor is the item dir (-1 or 1) from el in items, or null past either1395+// end: rows of buttons stop at their ends.1396+function neighbor(items, el, dir) {1397+ const i = items.indexOf(el);1398+ return i < 0 ? items[0] || null : items[i + dir] || null;1399+}1400+1401+// restoreFocus puts the focus back where it was in the view, after the dock1402+// or a field let it go.1403+function restoreFocus() {1404+ if (state.dock >= 0) return;1405+ if (state.view === 'tv' && osd.open) osdFocus(osd.last);1406+ else if (state.view === 'guide') guideZone(guideUI.zone);1407+ else if (state.view === 'recordings') recZone(recUI.zone);1408+ else if (state.view === 'antenna') antZone(antUI.zone);1409+ else if (state.view === 'weather') wxFocus();1410+}1411+1412+// viewBack is Back in an inner part of a view (a program's or a1413+// recording's actions, the guide's filters): it goes to the view's main1414+// part, and reports whether it did.1415+function viewBack() {1416+ if (state.view === 'guide' && guideUI.zone !== 'grid') { guideZone('grid'); return true; }1417+ if (state.view === 'recordings' && recUI.zone === 'actions') { recZone('list'); return true; }1418+ return false;1419+}1420+9291421 // ---------- game controllers ----------9301422 // A game controller does what the keyboard does: each button stands for a9311423 // key and goes through the same handler. Indexes are the standard gamepad
@@ -1044,8 +1536,8 @@ function padButtons(pads) {
10441536 function padPress(i, repeat) {10451537 setPrompts(true);10461538 // On the TV, A plays and pauses: the guide has its own button, and the1047- // remote's OK (Enter) still opens it.1048- if (i === 0 && state.view === 'tv' && !state.entry) return pressKey(' ', { repeat });1539+ // remote's OK (Enter) brings up the controls. With them up, A presses.1540+ if (i === 0 && state.view === 'tv' && !state.entry && !osd.open && state.dock < 0) return pressKey(' ', { repeat });10491541 const [key, shiftKey] = PAD_KEYS[i];10501542 pressKey(key, { shiftKey: !!shiftKey, repeat });10511543 }
@@ -1188,24 +1680,76 @@ function openGuide() {
11881680 const now = Date.now();11891681 state.gStart = floor30(now);11901682 state.gTime = now;1683+ guideZone('grid');11911684 const list = guideChannels();11921685 state.gRow = Math.max(0, list.findIndex((c) => state.current && c.key === state.current.key));11931686 renderGuide();11941687 scrollRowIntoView(true);11951688 }11961689 1690+// The guide's parts take the focus in turn (data-zone): the grid; the1691+// filters above it, Up from its first row; and the selected program's1692+// actions in the detail (Watch, Record, Series, New only, Favorite, Hide),1693+// which OK on a program to come brings up, as holding OK or I does on any1694+// (Kodi's and Android TV's long press for more). OK on what's on now1695+// watches it, as in the Live TV app's guide.1696+const guideUI = { zone: 'grid', act: '' };1697+1698+function guideZone(zone, act = '') {1699+ guideUI.zone = zone;1700+ $('#guide').dataset.zone = zone;1701+ if (act) guideUI.act = act;1702+ if (state.dock >= 0 || state.view !== 'guide') return;1703+ if (zone === 'grid') return blurIn($('#guide'));1704+ if (zone === 'filters') {1705+ const btns = $$('.g-filters button');1706+ focusEl(btns.find((b) => b.dataset.filter === state.guideFilter) || btns[0]);1707+ return;1708+ }1709+ const acts = guideActs();1710+ if (!acts.length) return guideZone('grid');1711+ focusEl(acts.find((b) => b.dataset.act === guideUI.act) || acts[0]);1712+}1713+1714+const guideActs = () => $$('#guide .gd-actions .act').filter(isShown);1715+1716+// guideRefocus puts the focus back on the filters or actions after they1717+// were drawn again.1718+function guideRefocus() {1719+ if (state.view !== 'guide' || state.dock >= 0 || guideUI.zone === 'grid') return;1720+ if (!$('#guide').contains(document.activeElement)) guideZone(guideUI.zone);1721+}1722+1723+function guideOpenActions() {1724+ if (state.view === 'guide' && guideChannels()[state.gRow]) guideZone('actions', '-');1725+}1726+11971727 function wireGuide() {1198- f($('#guide'), 'actions').addEventListener('click', (e) => {1728+ const actions = f($('#guide'), 'actions');1729+ actions.addEventListener('click', (e) => {11991730 const b = e.target.closest('[data-act]');12001731 if (!b) return;1201- if (b.dataset.act === 'once') recordSelected('once');1202- if (b.dataset.act === 'series') recordSelected('series', false);1203- if (b.dataset.act === 'new') recordSelected('series', true);1732+ const list = guideChannels();1733+ const ch = list[state.gRow];1734+ switch (b.dataset.act) {1735+ case 'watch': return watchChannel(ch);1736+ case 'once': return recordSelected('once');1737+ case 'series': return recordSelected('series', false);1738+ case 'new': return recordSelected('series', true);1739+ case 'fav': return toggleFavorite(ch);1740+ case 'hide': guideZone('grid'); return hideChannel(ch);1741+ default: return null;1742+ }1743+ });1744+ actions.addEventListener('focusin', (e) => {1745+ const b = e.target.closest('[data-act]');1746+ if (b) guideUI.act = b.dataset.act;12041747 });12051748 const rows = $('.g-rows');12061749 rows.addEventListener('click', (e) => {12071750 const cell = e.target.closest('[data-row]');12081751 if (!cell) return;1752+ guideZone('grid');12091753 state.gRow = Number(cell.dataset.row);12101754 if (cell.dataset.t) state.gTime = Number(cell.dataset.t);12111755 renderGuide();
@@ -1217,13 +1761,18 @@ function wireGuide() {
12171761 }12181762 12191763 function guideKey(e) {1764+ if (guideUI.zone === 'filters' && guideFiltersKey(e)) return;1765+ if (guideUI.zone === 'actions' && guideActionsKey(e)) return;12201766 const list = guideChannels();12211767 const sel = selectedProgram(list);12221768 switch (e.key) {1223- // Rows wrap around, so channels kept at the end of the lineup are one1224- // press away from the top. The dock is Left from the earliest program.1225- case 'ArrowUp': state.gRow = (state.gRow - 1 + list.length) % list.length; break;1226- case 'ArrowDown': state.gRow = (state.gRow + 1) % list.length; break;1769+ // Down from the last row wraps to the first; Up from the first goes to1770+ // the filters. The dock is Left from the earliest program.1771+ case 'ArrowUp':1772+ if (state.gRow <= 0) { e.preventDefault(); return guideZone('filters'); }1773+ state.gRow -= 1;1774+ break;1775+ case 'ArrowDown': state.gRow = list.length ? (state.gRow + 1) % list.length : 0; break;12271776 case 'PageUp': state.gRow = state.gRow === 0 ? list.length - 1 : Math.max(0, state.gRow - 8); break;12281777 case 'PageDown': state.gRow = state.gRow === list.length - 1 ? 0 : Math.min(list.length - 1, state.gRow + 8); break;12291778 case 'ArrowRight':
@@ -1239,16 +1788,22 @@ function guideKey(e) {
12391788 break;12401789 }12411790 case 'Home': state.gStart = floor30(Date.now()); state.gTime = Date.now(); break;1242- case 'Enter': guideActivate(); return;1791+ case 'Enter':1792+ e.preventDefault();1793+ // A key's press acts on its keyup, a hold brings up the actions; a1794+ // controller's A, at once.1795+ if (e.isTrusted) return holdStart(() => { if (state.view === 'guide' && guideUI.zone === 'grid') guideActivate(); }, guideOpenActions);1796+ return guideActivate();1797+ case 'i': case 'I': e.preventDefault(); return guideOpenActions();12431798 case 'r': e.preventDefault(); recordSelected('once'); return;12441799 case 'f': e.preventDefault(); toggleFavorite(list[state.gRow]); return;12451800 case 'h': case 'H': e.preventDefault(); hideChannel(list[state.gRow]); return;12461801 case 'R': e.preventDefault(); recordSelected('series', false); return;12471802 case 'n': case 'N': e.preventDefault(); recordSelected('series', true); return;12481803 default: {1249- const filters = guideFilters();1250- const i = filters.findIndex(([k]) => k === state.guideFilter);12511804 if (e.key === ']' || e.key === '[') {1805+ const filters = guideFilters();1806+ const i = filters.findIndex(([k]) => k === state.guideFilter);12521807 state.guideFilter = filters[(i + (e.key === ']' ? 1 : filters.length - 1)) % filters.length][0];12531808 state.gRow = 0;12541809 break;
@@ -1261,22 +1816,69 @@ function guideKey(e) {
12611816 scrollRowIntoView();12621817 }12631818 1819+// guideFiltersKey: Left and Right pick a filter (as a tab does), Down or OK1820+// goes to the grid, Up (or Left from the first) to the dock.1821+function guideFiltersKey(e) {1822+ const btns = $$('.g-filters button');1823+ const i = Math.max(0, btns.findIndex((b) => b.dataset.filter === state.guideFilter));1824+ switch (e.key) {1825+ case 'ArrowLeft': case 'ArrowRight': {1826+ const j = i + (e.key === 'ArrowLeft' ? -1 : 1);1827+ if (j < 0) { openDock(); break; }1828+ if (j >= btns.length) break;1829+ state.guideFilter = btns[j].dataset.filter;1830+ state.gRow = 0;1831+ renderGuide();1832+ $('.g-rows').scrollTop = 0;1833+ break;1834+ }1835+ case 'ArrowUp': openDock(); break;1836+ case 'ArrowDown': case 'Enter': guideZone('grid'); break;1837+ default: return false;1838+ }1839+ e.preventDefault();1840+ return true;1841+}1842+1843+// guideActionsKey: Left and Right along the actions, OK presses, Down (or1844+// Back) goes back to the grid, Up to the dock.1845+function guideActionsKey(e) {1846+ const acts = guideActs();1847+ const el = document.activeElement;1848+ switch (e.key) {1849+ case 'ArrowLeft': case 'ArrowRight': focusEl(neighbor(acts, el, e.key === 'ArrowLeft' ? -1 : 1) || (acts.includes(el) ? null : acts[0])); break;1850+ case 'ArrowUp': openDock(); break;1851+ case 'ArrowDown': guideZone('grid'); break;1852+ case 'Enter':1853+ if (acts.includes(el)) el.click(); else guideZone('actions');1854+ break;1855+ default: return false;1856+ }1857+ e.preventDefault();1858+ return true;1859+}1860+1861+// guideActivate is OK on a program: what's on now (or was, a little1862+// before) is watched on its channel, and a program to come shows what can1863+// be done with it.12641864 function guideActivate() {12651865 const list = guideChannels();12661866 const ch = list[state.gRow];12671867 if (!ch) return;12681868 const p = selectedProgram(list);1269- const now = Date.now();1270- if (!p || (p._s <= now && now < p._e)) {1271- // Choosing what's already playing goes back to it without tuning1272- // again (and losing the rewind buffer); a failed channel retries.1273- const playing = state.current && state.current.key === ch.key && !state.recording && $('#nosignal').hidden;1274- if (!playing) tune(ch);1275- setView('tv');1276- if (playing) showBanner();1277- } else {1278- toast(`${p.title} starts at ${clock(p._s)} on ${ch.number}`);1279- }1869+ if (!p || p._s <= Date.now()) watchChannel(ch);1870+ else guideOpenActions();1871+}1872+1873+// watchChannel goes back to TV on a channel. Choosing what's already1874+// playing goes back to it without tuning again (and losing the rewind1875+// buffer); a failed channel retries.1876+function watchChannel(ch) {1877+ if (!ch) return;1878+ const playing = state.current && state.current.key === ch.key && !state.recording && $('#nosignal').hidden;1879+ if (!playing) tune(ch);1880+ setView('tv');1881+ if (playing) showBanner();12801882 }12811883 12821884 function selectedProgram(list) {
@@ -1324,7 +1926,7 @@ function renderGuide() {
13241926 state.guideFilter = k;13251927 const n = guideChannels().length;13261928 state.guideFilter = saved;1327- return `<button data-filter="${k}" class="${k === state.guideFilter ? 'on' : ''}">${label}<span class="n">${n}</span></button>`;1929+ return `<button type="button" role="tab" aria-selected="${k === state.guideFilter}" data-filter="${k}" class="fx${k === state.guideFilter ? ' on' : ''}">${label}<span class="n">${n}</span></button>`;13281930 }).join('');13291931 $$('.g-filters button').forEach((b) => b.addEventListener('click', () => {13301932 state.guideFilter = b.dataset.filter;
@@ -1382,7 +1984,14 @@ function renderGuide() {
13821984 } else nl.style.display = 'none';13831985 13841986 renderGuideDetail(list[state.gRow], sel);1385- f(g, 'status').innerHTML = `${list.length} channels | ${hints([['Enter', 'Enter', 'watches'], ['f', 'F', 'favorite'], ['h', 'H', 'hide'], ['[', '[ ]', 'filter']])}`;1987+ f(g, 'status').innerHTML = `${list.length} channels | ${guideStatusHints()}`;1988+ guideRefocus();1989+}1990+1991+// guideStatusHints names the guide's keys beside its filters.1992+function guideStatusHints() {1993+ if (androidTV && !prompts.pad) return 'Up from the top row for filters';1994+ return hints([['i', 'I', 'more'], ['f', 'F', 'favorite'], ['h', 'H', 'hide'], ['[', '[ ]', 'filter']]);13861995 }13871996 13881997 // moveGuideSelection marks the selected row and program in rows already
@@ -1406,25 +2015,38 @@ function humanMinutes(m) {
14062015 return m % 60 ? `${h} h ${m % 60} min` : `${h} h`;14072016 }14082017 2018+// renderGuideActions draws the selected program's actions: Watch first for2019+// what's on now, the recording ones where recording applies (the2020+// antenna's channels, on a server with a DVR), then the channel's.14092021 function renderGuideActions(ch, p) {14102022 const box = f($('#guide'), 'actions');1411- if (!state.info.dvr || !p || p._e <= Date.now() || ch.weather || ch.custom) { box.innerHTML = ''; return; }1412- const it = state.dvrKeys.get(airingKey(ch, p));1413- const rule = seriesRuleFor(ch, p);2023+ if (!ch) { box.innerHTML = ''; return; }2024+ const now = Date.now();2025+ const airing = !p || p._s <= now;2026+ const act = (name, label, cls = '') => `<button type="button" class="act fx${cls ? ` ${cls}` : ''}" data-act="${name}">${label}</button>`;14142027 const parts = [];1415- if (it) {1416- const label = it.status === 'recording' ? 'Recording now' : it.status === 'unavailable' ? 'Channel not tuned on server' : 'Recording scheduled';1417- parts.push(`<span class="chip ${it.status === 'unavailable' ? '' : 'live'}">${label}</span>`);1418- if (it.id || !rule) parts.push('<button class="act" data-act="once">Cancel</button>');1419- } else {1420- parts.push('<button class="act rec" data-act="once"><i></i>Record</button>');1421- }1422- if (rule) {1423- parts.push(`<span class="chip new">Series${rule.newOnly ? ', new only' : ''}</span><button class="act" data-act="series">Stop series</button>`);1424- } else if (p.seriesId) {1425- parts.push('<button class="act" data-act="series">Record series</button><button class="act" data-act="new">New episodes only</button>');2028+ if (airing) parts.push(act('watch', !p || now < p._e ? 'Watch' : `Watch ${esc(ch.number)}`));2029+ if (state.info.dvr && p && p._e > now && !ch.weather && !ch.custom) {2030+ const it = state.dvrKeys.get(airingKey(ch, p));2031+ const rule = seriesRuleFor(ch, p);2032+ if (it) {2033+ const label = it.status === 'recording' ? 'Recording now' : it.status === 'unavailable' ? 'Channel not tuned on server' : 'Recording scheduled';2034+ parts.push(`<span class="chip ${it.status === 'unavailable' ? '' : 'live'}">${label}</span>`);2035+ if (it.id || !rule) parts.push(act('once', 'Cancel'));2036+ } else {2037+ parts.push(act('once', '<i></i>Record', 'rec'));2038+ }2039+ if (rule) {2040+ parts.push(`<span class="chip new">Series${rule.newOnly ? ', new only' : ''}</span>${act('series', 'Stop series')}`);2041+ } else if (p.seriesId) {2042+ parts.push(act('series', 'Record series') + act('new', 'New episodes only'));2043+ }14262044 }2045+ if (!airing) parts.push(act('watch', `Watch ${esc(ch.number)}`));2046+ parts.push(act('fav', isFav(ch) ? 'Unfavorite' : 'Favorite'));2047+ parts.push(act('hide', 'Hide channel'));14272048 box.innerHTML = parts.join('');2049+ guideRefocus();14282050 }14292051 14302052 function recordSelected(kind, newOnly) {
@@ -1473,7 +2095,12 @@ async function recordAiring(ch, p, kind, newOnly) {
14732095 14742096 function renderGuideDetail(ch, p) {14752097 const g = $('#guide');1476- if (!ch) return;2098+ if (!ch) {2099+ // No channels under this filter.2100+ for (const k of ['kicker', 'title', 'ep', 'meta', 'desc', 'actions', 'hint']) f(g, k).textContent = '';2101+ f(g, 'art').classList.remove('has');2102+ return;2103+ }14772104 const now = Date.now();14782105 f(g, 'kicker').innerHTML = `<span>${esc(ch.number)} ${esc(ch.own ? ch.call : displayCall(ch))}</span><span style="color:var(--muted)">${p ? `${clock(p._s)} to ${clock(p._e)}` : ''}</span>`;14792106 f(g, 'title').textContent = p ? p.title : ch.atsc3Only ? `${ch.network} (ATSC 3.0)` : ch.own ? ch.name : 'No listings';
@@ -1483,9 +2110,12 @@ function renderGuideDetail(ch, p) {
14832110 const genres = p ? p.genres || [] : ch.own && ch.category !== 'Other' ? [ch.category] : [];14842111 f(g, 'meta').innerHTML = chipsFor(p, ch) + genres.map((x) => `<span class="chip">${esc(x)}</span>`).join('');14852112 f(g, 'desc').textContent = p ? p.description || '' : ch.description || '';1486- const live = !p || (p._s <= now && now < p._e);1487- const when = live ? `${keyHint('Enter', 'Enter')} to watch` : `Starts in ${humanMinutes(Math.round((p._s - now) / MIN))}`;1488- f(g, 'hint').innerHTML = state.info.dvr && p && p._e > now && !ch.weather && !ch.custom && !prompts.pad && !androidTV ? `${when} | R record, Shift R series, N new only` : when;2113+ const live = !p || p._s <= now;2114+ const ok = keyHint('Enter', 'Enter');2115+ const when = !live ? `Starts in ${humanMinutes(Math.round((p._s - now) / MIN))} | ${ok} for more` : p && p._e <= now ? `Ended ${clock(p._e)} | ${ok} to watch ${esc(ch.number)}` : `${ok} to watch`;2116+ const more = !live ? '' : prompts.pad ? hints([['i', 'I', 'for more']]) : `hold ${ok} for more`;2117+ const rec = state.info.dvr && p && p._e > now && !ch.weather && !ch.custom && !prompts.pad && !androidTV ? ' | R record, Shift R series, N new only' : '';2118+ f(g, 'hint').innerHTML = `${when}${more ? `, ${more}` : ''}${rec}`;14892119 renderGuideActions(ch, p);14902120 const art = f(g, 'art');14912121 if (p && p.image) { art.style.backgroundImage = `url('${p.image}')`; art.classList.add('has'); } else { art.style.backgroundImage = ''; art.classList.remove('has'); }
@@ -1496,7 +2126,16 @@ function wireAntenna() {
14962126 const a = $('#antenna');14972127 f(a, 'preview').addEventListener('submit', (e) => {14982128 e.preventDefault();1499- startPreview(e.target.elements.zip.value.trim());2129+ const zip = e.target.elements.zip;2130+ zip.blur();2131+ antZone('rail', 'zip');2132+ // Once previewing, the focus goes to the preview's first button.2133+ antUI.key = 'copy';2134+ startPreview(zip.value.trim());2135+ });2136+ $('.rail', a).addEventListener('focusin', (e) => {2137+ const el = e.target.closest('[data-p], [data-act], .rail-preview label');2138+ if (el) antUI.key = el.dataset.p || el.dataset.act || 'zip';15002139 });15012140 f(a, 'previewing').addEventListener('click', (e) => {15022141 const b = e.target.closest('[data-act]');
@@ -1514,13 +2153,70 @@ function wireAntenna() {
15142153 });15152154 }15162155 2156+// Reception's parts take the focus in turn (data-zone): the transmitters'2157+// table, and the rail beside it, Left from the table: while previewing,2158+// Copy summary and Back to my setup; the antenna presets (OK picks one);2159+// and Preview a ZIP code, where OK brings up the keyboard.2160+const antUI = { zone: 'table', key: '' };2161+2162+function openAntenna() {2163+ renderAntenna();2164+ antZone(stationOrder().length ? 'table' : 'rail');2165+}2166+2167+function antRail() {2168+ const a = $('#antenna');2169+ return [...$$('[data-f="previewing"] .fx', a), ...$$('[data-f="presets"] .fx', a), $('.rail-preview label', a)].filter(isShown);2170+}2171+2172+function antZone(zone, key = '') {2173+ antUI.zone = zone;2174+ $('#antenna').dataset.zone = zone;2175+ if (key) antUI.key = key;2176+ if (state.dock >= 0 || state.view !== 'antenna') return;2177+ if (zone === 'table') return blurIn($('#antenna'));2178+ const items = antRail();2179+ const keyOf = (el) => el.dataset.p || el.dataset.act || 'zip';2180+ focusEl(items.find((el) => keyOf(el) === antUI.key) || items.find((el) => el.classList.contains('on')) || items[0]);2181+}2182+2183+function antRefocus() {2184+ if (state.view !== 'antenna' || state.dock >= 0 || antUI.zone !== 'rail') return;2185+ const el = document.activeElement;2186+ if (el && el.matches(TEXT_INPUT)) return;2187+ if (!$('#antenna').contains(el) || !isShown(el)) antZone('rail');2188+}2189+15172190 function antennaKey(e) {2191+ if (e.key === '1' || e.key === '2' || e.key === '3') return setPreset(state.presets[Number(e.key) - 1].name);2192+ if (antUI.zone === 'rail') {2193+ const items = antRail();2194+ const el = document.activeElement;2195+ const i = items.indexOf(el);2196+ switch (e.key) {2197+ case 'ArrowUp': if (i > 0) focusEl(items[i - 1]); else openDock(); break;2198+ case 'ArrowDown': focusEl(items[Math.min(items.length - 1, i + 1)]); break;2199+ case 'ArrowRight': if (stationOrder().length) antZone('table'); break;2200+ case 'ArrowLeft': openDock(); break;2201+ case 'Enter':2202+ if (el && el.matches('.rail-preview label')) {2203+ const input = $('input', el);2204+ input.focus();2205+ input.select();2206+ } else if (i >= 0) el.click();2207+ else antZone('rail');2208+ break;2209+ default: return;2210+ }2211+ e.preventDefault();2212+ return;2213+ }15182214 const keys = stationOrder().map((s) => `${s.facilityId}:${s.rfChannel}`);15192215 let i = keys.indexOf(state.aSel);1520- if ((e.key === 'ArrowUp' && i <= 0) || e.key === 'ArrowLeft') { e.preventDefault(); return openDock(); }2216+ if (e.key === 'ArrowUp' && i <= 0) { e.preventDefault(); return openDock(); }2217+ if (e.key === 'ArrowLeft') { e.preventDefault(); return antZone('rail'); }15212218 if (e.key === 'ArrowDown') i = Math.min(keys.length - 1, i + 1);15222219 else if (e.key === 'ArrowUp') i = Math.max(0, i - 1);1523- else if (e.key === '1' || e.key === '2' || e.key === '3') return setPreset(state.presets[Number(e.key) - 1].name);15242220 else return;15252221 e.preventDefault();15262222 selectStation(keys[i]);
@@ -1558,7 +2254,7 @@ function renderAntenna() {
15582254 const where = { indoor: 'Inside, by a window', attic: 'Under the roof', rooftop: 'Outside, 30 ft up' };15592255 f(a, 'presets').innerHTML = state.presets.map((p, i) => {15602256 const n = rep.channels.filter((c) => receivable(c.tier && c.tier[p.name])).length;1561- return `<button class="rm-item ${p.name === preset ? 'on' : ''}" data-p="${p.name}" title="Key ${i + 1}">2257+ return `<button type="button" role="radio" aria-checked="${p.name === preset}" class="rm-item fx ${p.name === preset ? 'on' : ''}" data-p="${p.name}" title="Key ${i + 1}">15622258 <span class="rm-label">${esc(p.name[0].toUpperCase() + p.name.slice(1))}</span><span class="rm-count">${n}</span>15632259 <small>${where[p.name] || ''}</small></button>`;15642260 }).join('');
@@ -1573,6 +2269,7 @@ function renderAntenna() {
15732269 renderNextGen(preset);15742270 if (!state.aSel && good[0]) state.aSel = `${good[0].facilityId}:${good[0].rfChannel}`;15752271 if (state.aSel) selectStation(state.aSel, true);2272+ antRefocus();15762273 }15772274 15782275 function renderTable(stations, preset) {
@@ -1820,6 +2517,8 @@ const AUDIO_LANGS = [['', 'As broadcast'], ['en', 'English'], ['es', 'Spanish'],
18202517 const GUIDE_HOURS = [6, 12, 24, 36, 48, 72];18212518 const WATCHED = [[0, 'Keep them'], [1, 'Delete after a day'], [7, 'Delete after a week'], [30, 'Delete after a month']];18222519 const SETTINGS_LISTS = { hidden: 'Hidden channels', controls: 'Remote, controller and keys', sources: 'Data sources' };2520+// How to hide a channel, with the keys there are.2521+const HIDE_HOW = () => (androidTV && !prompts.pad ? 'In the guide, hold OK on a channel\'s program: Hide channel' : 'In the guide, H hides a channel');18232522 18242523 // padNames names face buttons ("A, Y") as the pad in use labels them, by18252524 // their place in the standard mapping.
@@ -1832,11 +2531,11 @@ const padNames = (names) => names.replace(/\b[ABXY]\b/g, (n) => (FACES[prompts.f
18322531 // CONTROLS is the legend of the controls list: glyph keys, the controller's18332532 // buttons by name (for a keyboard user), the keys, and what they do.18342533 const CONTROLS = [1835- ['Arrows', 'D-pad, left stick', 'Arrows', 'Move. On TV, Up and Down change channel, Left and Right go back 10 s or ahead 30 s'],1836- ['Enter', 'A', 'Enter', 'Choose. On TV, A plays and pauses, and Enter (the remote\'s OK) opens the guide'],1837- ['Escape', 'B', 'Esc, Back', 'Back'],2534+ ['Arrows', 'D-pad, left stick', 'Arrows', 'Move. On TV, Up and Down change channel, Left and Right go back 10 s or ahead 30 s and bring up the controls'],2535+ ['Enter', 'A', 'Enter', 'Choose. On TV, Enter (the remote\'s OK) brings up the controls, Guide first, and A plays and pauses'],2536+ ['Escape', 'B', 'Esc, Back', 'Back, and puts the controls away'],18382537 ['g', 'Y', 'G', 'Guide'],1839- ['i', 'X', 'I', 'Program info, twice for reception'],2538+ ['i', 'X', 'I', 'Program info, twice for reception. In the guide, more for a program: record, favorite, hide'],18402539 ['PageUp PageDown', 'LB, RB', 'Page Up, Page Down', 'Channel down or up, a page of the guide or of Settings'],18412540 ['ShiftLeft ShiftRight', 'LT, RT', 'Shift Left, Shift Right', 'Back or ahead a minute'],18422541 ['Space', 'R3', 'Space, Play', 'Pause'],
@@ -1947,7 +2646,7 @@ function settingsRows() {
19472646 }19482647 19492648 const hidden = (s.hidden || []).length;1950- add('Channels', { id: 'hidden', kind: 'open', label: 'Hidden channels', sub: 'In the guide, H hides a channel', value: hidden ? `${hidden} hidden` : 'None', open: 'hidden' });2649+ add('Channels', { id: 'hidden', kind: 'open', label: 'Hidden channels', sub: HIDE_HOW(), value: hidden ? `${hidden} hidden` : 'None', open: 'hidden' });19512650 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 });19522651 19532652 // In a browser the server is the one the page came from (web.js).
@@ -1984,7 +2683,7 @@ function settingsList(name) {
19842683 for (const c of hidden) {19852684 add({ id: c.key, kind: 'action', verb: 'shows it', label: `${c.number} ${c.network || displayCall(c)}`.trim(), value: 'Show', run: () => showHidden([c.key]) });19862685 }1987- if (!hidden.length) add({ id: 'none', kind: 'info', label: 'No hidden channels', sub: 'In the guide, H hides a channel' });2686+ if (!hidden.length) add({ id: 'none', kind: 'info', label: 'No hidden channels', sub: HIDE_HOW() });19882687 }19892688 if (name === 'controls') {19902689 for (const [keys, pad, label, what] of CONTROLS) {
@@ -2021,9 +2720,9 @@ function renderSettings() {
20212720 });20222721 if (section) html += '</div>';20232722 if (settingsUI.sub === 'controls' && androidTV) {2024- html += '<p class="s-note">On the remote: OK opens the guide, Back goes back, and digits tune a channel. Channel up and down change channel, Play/Pause pauses, Fast forward and Rewind skip.</p>';2723+ html += '<p class="s-note">On the remote: OK brings up the controls over the picture, with Guide first, so OK twice opens the guide. In them Left and Right move along a row, Up and Down between rows: the timeline (Left and Right skip), the buttons (play, captions, record, last channel and more), and the views at the top. On live TV Up and Down change channel, and Left and Right skip. In the guide OK watches what\'s on, and holding OK offers more: record, series, favorite, hide. Back goes back. Remotes with them: digits tune a channel, Channel up and down change channel, Play/Pause pauses.</p>';20252724 } else if (settingsUI.sub === 'controls') {2026- html += '<p class="s-note">Digits tune a channel. The remote\'s Fast forward and Rewind skip, Record records. Keyboard only: L last channel, C captions, V audio track, F favorite, R record, D recordings, A reception, Shift F full screen. In Steam, give Airwaves the Gamepad controller layout, not a keyboard one.</p>';2725+ html += '<p class="s-note">Enter on TV brings up the controls, with the guide, captions, recording, the last channel and the views; Esc puts them away. Digits tune a channel. The remote\'s Fast forward and Rewind skip, Record records. Keyboard only: L last channel, C captions, V audio track, F favorite, R record, D recordings, A reception, Shift F full screen. In the guide hold Enter, or press I, for a program\'s actions. In Steam, give Airwaves the Gamepad controller layout, not a keyboard one.</p>';20272726 }20282727 const keep = list.scrollTop;20292728 list.innerHTML = html;
@@ -2317,8 +3016,10 @@ function applyScale() {
23173016 root.style.zoom = z === 1 ? '' : String(z);23183017 root.style.setProperty('--zoom', String(z));23193018 }2320- // Larger sizes leave less room above the guide (.tight in style.css).2321- root.classList.toggle('tight', z > 1 && (native ? innerHeight : innerHeight / z) < 900);3019+ // Larger sizes leave less room above the guide (.tight in style.css):3020+ // the size chosen, not the zoom, which on the Android TV app also fits a3021+ // 1080p screen's interface into the WebView.3022+ root.classList.toggle('tight', z / fit > 1 && (native ? innerHeight : innerHeight / z) < 900);23223023 fitWX();23233024 if (state.view === 'guide' && state.report) renderGuide();23243025 }
@@ -2362,7 +3063,7 @@ function renderRecordingBanner(b, it) {
23623063 const pos = (state.recOffset || 0) + (video.currentTime || 0);23633064 f(b, 'progress').style.width = it.duration ? `${Math.min(100, (pos / it.duration) * 100)}%` : '0';23643065 f(b, 'desc').textContent = it.description || '';2365- f(b, 'next').innerHTML = hints([['LeftRight', 'Arrows', 'skip'], [prompts.pad ? 'Enter' : ' ', 'Space', 'pauses'], ['Escape', 'Esc', 'returns to live TV']]);3066+ f(b, 'next').innerHTML = hints([['LeftRight', 'Arrows', 'skip'], ...(androidTV || prompts.pad ? [] : [[' ', 'Space', 'pauses']]), ['Enter', 'Enter', 'controls'], ['Escape', 'Esc', 'returns to live TV']]);23663067 f(b, 'meter').innerHTML = '';23673068 f(b, 'tier').textContent = sizeLabel(it.sizeBytes);23683069 f(b, 'tier').style.color = '';
@@ -2385,7 +3086,10 @@ const dayLabel = (t) => {
23853086 return d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });23863087 };23873088 3089+// A delete, a skip or a stopped series asks for a second press: the3090+// button says so (armed) until then, for a few seconds.23883091 let pendingDelete = '';3092+let pendingTimer = 0;23893093 23903094 const REC_TABS = [['library', 'Library'], ['upcoming', 'Coming up'], ['series', 'Series']];23913095
@@ -2398,14 +3102,52 @@ function recLists() {
23983102 };23993103 }24003104 3105+// The view's parts take the focus in turn (data-zone): the list, the menu3106+// of lists in the rail (Left from the list's left edge), and the selected3107+// item's actions (OK): for a recording Play (or Resume), From the start,3108+// Watched and Delete, above the grid; for one to come, Don't record; for a3109+// series, how many to keep, new episodes only, and Stop.3110+const recUI = { zone: 'list', act: '' };3111+3112+function openRecordings() {3113+ const tab = state.recTab || 'library';3114+ renderRecordings();3115+ recZone(recLists()[tab].length ? 'list' : 'menu');3116+}3117+3118+function recZone(zone, act = '') {3119+ recUI.zone = zone;3120+ $('#recordings').dataset.zone = zone;3121+ if (act) recUI.act = act;3122+ if (state.dock >= 0 || state.view !== 'recordings') return;3123+ if (zone === 'list') return blurIn($('#recordings'));3124+ if (zone === 'menu') return void focusEl($(`#recordings [data-tab="${state.recTab || 'library'}"]`));3125+ const acts = recActs();3126+ if (!acts.length) return recZone('list');3127+ focusEl(acts.find((b) => b.dataset.act === recUI.act) || acts[0]);3128+}3129+3130+const recActs = () => $$('#recordings .r-panel.on .act.fx').filter(isShown);3131+3132+function recRefocus() {3133+ if (state.view !== 'recordings' || state.dock >= 0 || recUI.zone === 'list') return;3134+ if (!$('#recordings').contains(document.activeElement) || !isShown(document.activeElement)) recZone(recUI.zone);3135+}3136+3137+// armed is whether a delete-like action waits for its second press; ARMED3138+// says what the second does.3139+const armed = (act, it) => pendingDelete === `${act}:${it && it.id}`;3140+const ARMED = { delete: 'delete', skip: 'skip it', stop: 'stop it' };3141+24013142 function renderRecordings() {24023143 const v = $('#recordings');24033144 const lists = recLists();24043145 const tab = state.recTab || 'library';24053146 const sel = state.recSelBy || (state.recSelBy = { library: 0, upcoming: 0, series: 0 });24063147 for (const k of Object.keys(sel)) sel[k] = Math.min(sel[k], Math.max(0, lists[k].length - 1));3148+ const act = (name, label, it, cls = '') => `<button type="button" class="act fx${cls ? ` ${cls}` : ''}${armed(name, it) ? ' armed' : ''}" data-act="${name}">${armed(name, it) ? `${androidTV ? 'OK' : 'Press'} again to ${ARMED[name]}` : label}</button>`;24073149 2408- f(v, 'menu').innerHTML = REC_TABS.map(([k, label]) => `<button class="rm-item ${k === tab ? 'on' : ''}" data-tab="${k}"><span class="rm-label">${label}</span><span class="rm-count">${lists[k].length}</span></button>`).join('');3150+ 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('');24093151 $$('.r-panel', v).forEach((p) => p.classList.toggle('on', p.dataset.panel === tab));24103152 24113153 // Library: the selected recording above a grid of everything recorded.
@@ -2417,10 +3159,11 @@ function renderRecordings() {
24173159 <div class="rd-title">${esc(cur.title)}</div>24183160 <div class="rd-ep">${esc(cur.subtitle || '')}</div>24193161 <p class="rd-desc">${esc(cur.status === 'failed' ? cur.detail || 'This recording failed' : cur.description || '')}</p>2420- <div class="rd-hint">${hints([['Enter', 'Enter', resumable(cur) ? `resumes at ${mmss(cur.position)}` : 'plays'], ...(resumable(cur) ? [['', 'Shift Enter', 'from the start']] : []), ['w', 'W', cur.watched ? 'unwatched' : 'watched'], ['', 'Delete', 'removes']])}</div>3162+ <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>3163+ <div class="rd-hint">${recHint(cur)}</div>24213164 </div>24223165 <div class="rd-art" style="${cur.image ? `background-image:url('${esc(cur.image)}')` : ''}"></div>`2423- : '<div class="rd-empty">Nothing recorded yet. In the guide, press R to record a program, or Shift R for every episode.</div><div></div>';3166+ : '<div class="rd-empty">Nothing recorded yet. In the guide, OK on a program to come records it, or every episode.</div><div></div>';24243167 f(v, 'library').innerHTML = lib.map((it, i) => `24253168 <article class="r-card ${i === sel.library ? 'sel' : ''} ${it.status === 'failed' ? 'failed' : ''} ${it.watched ? 'watched' : ''}" data-i="${i}">24263169 <div class="r-art" style="${it.image ? `background-image:url('${esc(it.image)}')` : ''}">${it.image ? '' : `<span>${esc(it.title)}</span>`}
@@ -2437,16 +3180,22 @@ function renderRecordings() {
24373180 <li class="r-item ${it.status} ${i === sel.upcoming ? 'sel' : ''}" data-i="${i}">24383181 <div class="r-time">${dayLabel(it.start)}<b>${clock(it.start)}</b></div>24393182 <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>2440- ${it.status === 'recording' ? '<span class="chip live">Recording now</span>' : it.status === 'unavailable' ? '<span class="chip">Channel not available</span>' : ''}</div>2441- ${it.id ? `<button class="x" title="Don't record this" data-del="${esc(it.id)}">✕</button>` : '<span></span>'}3183+ ${it.status === 'recording' ? '<span class="chip live">Recording now</span>' : it.status === 'unavailable' ? '<span class="chip">Channel not available</span>' : ''}${!it.id ? '<span class="chip">Part of a series</span>' : ''}</div>3184+ <div class="r-acts">${i === sel.upcoming && it.id ? act('skip', "Don't record", it) : ''}</div>24423185 </li>`).join('') : '<li class="r-empty">Nothing scheduled.</li>';24433186 24443187 f(v, 'rules').innerHTML = lists.series.length ? lists.series.map((r, i) => `24453188 <li class="r-item ${i === sel.series ? 'sel' : ''}" data-i="${i}">24463189 <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>2447- <button class="x" title="Stop recording this series" data-rule="${esc(r.id)}">✕</button>2448- </li>`).join('') : '<li class="r-empty">No series recordings. In the guide, press Shift R on a show.</li>';2449- if (lists.series.length && !prompts.pad) f(v, 'rules').insertAdjacentHTML('beforeend', '<li class="r-hint">K changes how many to keep | N new episodes only | Delete stops the series</li>');3190+ <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>3191+ </li>`).join('') : '<li class="r-empty">No series recordings. In the guide, OK on a show to come, then Record series.</li>';3192+ if (lists.series.length && !prompts.pad && !androidTV) f(v, 'rules').insertAdjacentHTML('beforeend', '<li class="r-hint">K changes how many to keep | N new episodes only | Delete stops the series</li>');3193+ recRefocus();3194+}3195+3196+function recHint(cur) {3197+ if (androidTV && !prompts.pad) return 'OK for the actions, Left for the other lists';3198+ return hints([['Enter', 'Enter', 'for the actions'], ...(resumable(cur) ? [['', 'Shift Enter', 'plays from the start']] : []), ['w', 'W', cur.watched ? 'unwatched' : 'watched'], ['', 'Delete', 'removes']]);24503199 }24513200 24523201 function wireRecordings() {
@@ -2457,23 +3206,19 @@ function wireRecordings() {
24573206 state.recTab = b.dataset.tab;24583207 renderRecordings();24593208 });2460- v.addEventListener('click', async (e) => {2461- const del = e.target.closest('[data-del]');2462- const rule = e.target.closest('[data-rule]');2463- if (del || rule) {2464- try {2465- if (del) { await api().DeleteRecording(del.dataset.del); toast('Removed from the schedule'); }2466- if (rule) { await api().DeleteRule(rule.dataset.rule); toast('Series recording stopped'); }2467- } catch (err) {2468- toast(String(err && err.message ? err.message : err), 6000);2469- }2470- return loadDVR();2471- }3209+ v.addEventListener('click', (e) => {3210+ const b = e.target.closest('[data-act]');3211+ if (b) return recAct(b.dataset.act);24723212 const item = e.target.closest('[data-i]');24733213 if (item && state.recSelBy) {24743214 state.recSelBy[state.recTab || 'library'] = Number(item.dataset.i);24753215 renderRecordings();24763216 }3217+ return null;3218+ });3219+ v.addEventListener('focusin', (e) => {3220+ const b = e.target.closest('[data-act]');3221+ if (b) recUI.act = b.dataset.act;24773222 });24783223 f(v, 'library').addEventListener('dblclick', (e) => {24793224 const card = e.target.closest('[data-i]');
@@ -2484,6 +3229,44 @@ function wireRecordings() {
24843229 });24853230 }24863231 3232+// recAct does one of the selected item's actions.3233+function recAct(act) {3234+ const tab = state.recTab || 'library';3235+ const it = recLists()[tab][(state.recSelBy || {})[tab] || 0];3236+ if (!it) return null;3237+ const fail = (err) => toast(String(err && err.message ? err.message : err), 6000);3238+ switch (act) {3239+ case 'play': return playRecording(it, resumable(it) ? it.position : 0);3240+ case 'restart': return playRecording(it, 0);3241+ case 'watched': return api().MarkWatched(it.id, !it.watched).then(loadDVR).catch(fail);3242+ case 'keep': case 'newonly': {3243+ const steps = [0, 3, 5, 10];3244+ const u = act === 'keep' ? { keep: steps[(steps.indexOf(it.keep || 0) + 1) % steps.length], newOnly: !!it.newOnly } : { keep: it.keep || 0, newOnly: !it.newOnly };3245+ return api().UpdateRule(it.id, u).then(loadDVR).catch(fail);3246+ }3247+ case 'delete': case 'skip': case 'stop': {3248+ if (!it.id) return null;3249+ if (!armed(act, it)) {3250+ pendingDelete = `${act}:${it.id}`;3251+ clearTimeout(pendingTimer);3252+ pendingTimer = setTimeout(() => { pendingDelete = ''; if (state.view === 'recordings') renderRecordings(); }, 4000);3253+ const what = act === 'delete' ? `delete ${it.title}` : act === 'stop' ? `stop recording ${it.title}` : `skip ${it.title}`;3254+ if (recUI.zone !== 'actions') toast(`Press Delete again to ${what}`);3255+ return renderRecordings();3256+ }3257+ pendingDelete = '';3258+ clearTimeout(pendingTimer);3259+ const done = act === 'stop' ? api().DeleteRule(it.id) : api().DeleteRecording(it.id);3260+ return done.then(() => {3261+ toast(act === 'delete' ? 'Deleted' : act === 'stop' ? 'Series recording stopped' : 'Removed from the schedule');3262+ recZone('list');3263+ return loadDVR();3264+ }).catch(fail);3265+ }3266+ default: return null;3267+ }3268+}3269+24873270 function recordingsKey(e) {24883271 const tab = state.recTab || 'library';24893272 const lists = recLists();
@@ -2494,62 +3277,92 @@ function recordingsKey(e) {
24943277 state.recTab = REC_TABS[(i + (e.key === ']' ? 1 : REC_TABS.length - 1)) % REC_TABS.length][0];24953278 return renderRecordings();24963279 }3280+ if (recUI.zone === 'menu') return recMenuKey(e);3281+ if (recUI.zone === 'actions' && recActionsKey(e)) return undefined;24973282 const cols = tab === 'library' ? Math.max(1, Math.round(f($('#recordings'), 'library').clientWidth / 280)) : 1;2498- if ((e.key === 'ArrowUp' && (!list.length || sel[tab] < cols)) || (e.key === 'ArrowLeft' && (tab !== 'library' || !sel[tab]))) {2499- e.preventDefault();2500- return openDock();2501- }2502- if (!list.length) return;3283+ if (e.key === 'ArrowUp' && (!list.length || sel[tab] < cols)) { e.preventDefault(); return openDock(); }3284+ if (e.key === 'ArrowLeft' && (tab !== 'library' || sel[tab] % cols === 0)) { e.preventDefault(); return recZone('menu'); }3285+ if (!list.length) return undefined;25033286 switch (e.key) {2504- case 'ArrowRight': if (tab === 'library') sel[tab] = Math.min(list.length - 1, sel[tab] + 1); break;2505- case 'ArrowLeft': if (tab === 'library') sel[tab] = Math.max(0, sel[tab] - 1); break;3287+ case 'ArrowRight':3288+ if (tab === 'library') sel[tab] = Math.min(list.length - 1, sel[tab] + 1);3289+ else { e.preventDefault(); return recZone('actions', '-'); }3290+ break;3291+ case 'ArrowLeft': sel[tab] = Math.max(0, sel[tab] - 1); break;25063292 case 'ArrowDown': sel[tab] = Math.min(list.length - 1, sel[tab] + cols); break;25073293 case 'ArrowUp': sel[tab] = Math.max(0, sel[tab] - cols); break;2508- case 'Enter': {3294+ case 'Enter':25093295 e.preventDefault();2510- const it = list[sel[tab]];2511- if (tab === 'library') playRecording(it, !e.shiftKey && resumable(it) ? it.position : 0);2512- return;2513- }2514- case 'w': case 'W':2515- if (tab === 'library') {2516- const it = list[sel[tab]];2517- api().MarkWatched(it.id, !it.watched).then(loadDVR);2518- }2519- return;2520- case 'k': case 'K': case 'n': case 'N':2521- if (tab === 'series') {2522- const r = list[sel[tab]];2523- const steps = [0, 3, 5, 10];2524- const u = e.key.toLowerCase() === 'k'2525- ? { keep: steps[(steps.indexOf(r.keep || 0) + 1) % steps.length], newOnly: !!r.newOnly }2526- : { keep: r.keep || 0, newOnly: !r.newOnly };2527- api().UpdateRule(r.id, u).then(loadDVR).catch((err) => toast(String(err), 6000));2528- }2529- return;2530- case 'Delete': case 'Backspace': {3296+ // Shift Enter, on a keyboard, plays a recording from its start.3297+ if (tab === 'library' && e.shiftKey) return playRecording(list[sel[tab]], 0);3298+ return recZone('actions', '-');3299+ case 'w': case 'W': return tab === 'library' ? recAct('watched') : undefined;3300+ case 'k': case 'K': return tab === 'series' ? recAct('keep') : undefined;3301+ case 'n': case 'N': return tab === 'series' ? recAct('newonly') : undefined;3302+ case 'Delete': case 'Backspace':25313303 e.preventDefault();2532- const it = list[sel[tab]];2533- const id = it.id;2534- if (!id) return;2535- if (pendingDelete !== id) {2536- pendingDelete = id;2537- const what = tab === 'library' ? `delete ${it.title}` : tab === 'series' ? `stop recording ${it.title}` : `skip ${it.title}`;2538- toast(`Press Delete again to ${what}`);2539- setTimeout(() => { if (pendingDelete === id) pendingDelete = ''; }, 4000);2540- return;2541- }2542- pendingDelete = '';2543- const done = tab === 'series' ? api().DeleteRule(id) : api().DeleteRecording(id);2544- done.then(() => { toast('Done'); loadDVR(); }).catch((err) => toast(String(err), 6000));2545- return;2546- }2547- default: return;3304+ return recAct(tab === 'library' ? 'delete' : tab === 'series' ? 'stop' : 'skip');3305+ default: return undefined;25483306 }25493307 e.preventDefault();25503308 renderRecordings();25513309 const el = $(`#recordings .r-panel.on [data-i="${sel[tab]}"]`);25523310 if (el) el.scrollIntoView({ block: 'nearest' });3311+ return undefined;3312+}3313+3314+// recMenuKey: Up and Down pick a list (as tabs do), Right or OK goes into3315+// it, Left (or Up from the first) to the dock.3316+function recMenuKey(e) {3317+ const i = REC_TABS.findIndex(([k]) => k === (state.recTab || 'library'));3318+ switch (e.key) {3319+ case 'ArrowUp': case 'ArrowDown': {3320+ const j = i + (e.key === 'ArrowUp' ? -1 : 1);3321+ if (j < 0) return openDock();3322+ if (j >= REC_TABS.length) break;3323+ state.recTab = REC_TABS[j][0];3324+ renderRecordings();3325+ break;3326+ }3327+ case 'ArrowRight': case 'Enter':3328+ if (recLists()[state.recTab || 'library'].length) recZone('list');3329+ break;3330+ case 'ArrowLeft': return openDock();3331+ default: return undefined;3332+ }3333+ e.preventDefault();3334+ return undefined;3335+}3336+3337+// recActionsKey: Left and Right along the actions, OK presses. Above the3338+// library's grid, Down goes back to it and Up to the dock; in a list Up3339+// and Down move to the item above or below, and Left from the first3340+// action back to the item.3341+function recActionsKey(e) {3342+ const acts = recActs();3343+ const el = document.activeElement;3344+ const lib = (state.recTab || 'library') === 'library';3345+ switch (e.key) {3346+ case 'ArrowLeft': case 'ArrowRight': {3347+ const next = neighbor(acts, el, e.key === 'ArrowLeft' ? -1 : 1);3348+ if (next) focusEl(next);3349+ else if (e.key === 'ArrowLeft' && !lib) recZone('list');3350+ break;3351+ }3352+ case 'ArrowUp': case 'ArrowDown':3353+ if (lib) {3354+ if (e.key === 'ArrowUp') openDock(); else recZone('list');3355+ break;3356+ }3357+ recZone('list');3358+ return false; // and moves in the list3359+ case 'Enter':3360+ if (acts.includes(el)) el.click(); else recZone('actions');3361+ break;3362+ default: return false;3363+ }3364+ e.preventDefault();3365+ return true;25533366 }25543367 25553368 const resumable = (it) => !!it && !it.watched && it.position > 30 && it.duration > 0 && it.position < it.duration - 60;
@@ -2568,7 +3381,6 @@ async function playRecording(it, from = 0) {
25683381 const pb = await api().PlayRecording(it.id, from);25693382 if (token !== state.tuneToken) return;25703383 state.recOffset = pb.offset || 0;2571- video.controls = true;25723384 await play(pb, token);25733385 } catch (e) {25743386 if (token !== state.tuneToken) return;
@@ -2600,7 +3412,7 @@ async function hideChannel(ch) {
26003412 state.lastHidden = ch.key;26013413 await saveAppSettings({ ...state.settings, hidden: [...hidden] });26023414 buildLineup();2603- toast(`Hid ${ch.number} ${ch.network || displayCall(ch)}. Press U to undo.`, 5000);3415+ toast(`Hid ${ch.number} ${ch.network || displayCall(ch)}. ${androidTV && !prompts.pad ? 'Settings, Hidden channels shows it again.' : 'Press U to undo.'}`, 5000);26043416 if (state.view === 'guide') renderGuide();26053417 }26063418
@@ -3304,6 +4116,7 @@ function saveRecordingProgress() {
33044116 33054117 video.addEventListener('timeupdate', () => {33064118 if ($('#banner').classList.contains('show')) showTimeshift();4119+ if (osd.open) renderTimeline();33074120 if (state.recording && Date.now() - progressTimer > 15000) {33084121 progressTimer = Date.now();33094122 saveRecordingProgress();
@@ -3312,6 +4125,14 @@ video.addEventListener('timeupdate', () => {
33124125 video.addEventListener('pause', () => { if (state.recording) saveRecordingProgress(); });33134126 video.addEventListener('play', () => updatePlaybackState());33144127 video.addEventListener('pause', () => updatePlaybackState());4128+// The controls stay while paused, and count down again once playing.4129+for (const ev of ['play', 'pause', 'playing', 'emptied']) {4130+ video.addEventListener(ev, () => {4131+ if (!osd.open) return;4132+ renderOSD();4133+ osdTouch();4134+ });4135+}33154136 video.addEventListener('playing', () => {33164137 if (!state.recording && state.liveBaseline == null) state.liveBaseline = Math.max(0, liveEdge() - video.currentTime);33174138 applyCaptions();
@@ -3724,8 +4545,71 @@ function renderCrawl() {
37244545 }37254546 }37264547 4548+// ---------- weather view ----------4549+// Its parts take the focus in turn, Up and Down, scrolling with it: the4550+// alerts, the next 24 hours, the week, the maps. Left goes to Watch the4551+// weather channel in the rail, when there is one, and from there to the4552+// dock, as does Up from the first part.4553+const wxUI = { at: 0 };4554+const wxBlocks = () => $$('#weather .wx-main > .fx').filter(isShown);4555+const wxWatch = () => $('#weather .wx-watch');4556+4557+function wireWeather() {4558+ const v = $('#weather');4559+ wxWatch().addEventListener('click', () => {4560+ const ch = weatherChannel();4561+ if (!ch) return;4562+ if (!(state.current && state.current.weather && !state.recording)) tune(ch);4563+ setView('tv');4564+ });4565+ $('.wx-main', v).addEventListener('focusin', (e) => {4566+ const i = wxBlocks().indexOf(e.target);4567+ if (i >= 0) wxUI.at = i;4568+ });4569+}4570+4571+function wxFocus() {4572+ if (state.dock >= 0 || state.view !== 'weather') return;4573+ const blocks = wxBlocks();4574+ const i = Math.min(wxUI.at, blocks.length - 1);4575+ if (i < 0) return void (isShown(wxWatch()) && focusEl(wxWatch()));4576+ focusEl(blocks[i]);4577+ if (i === 0) $('#weather .wx-main').scrollTop = 0;4578+}4579+4580+function weatherKey(e) {4581+ const el = document.activeElement;4582+ const blocks = wxBlocks();4583+ if (el === wxWatch()) {4584+ switch (e.key) {4585+ case 'ArrowRight': if (blocks.length) wxFocus(); break;4586+ case 'ArrowLeft': case 'ArrowUp': openDock(); break;4587+ case 'ArrowDown': break;4588+ case 'Enter': el.click(); break;4589+ default: return;4590+ }4591+ e.preventDefault();4592+ return;4593+ }4594+ const i = blocks.indexOf(el);4595+ switch (e.key) {4596+ case 'ArrowUp':4597+ if (i > 0) { wxUI.at = i - 1; wxFocus(); } else openDock();4598+ break;4599+ case 'ArrowDown':4600+ if (i < 0) wxFocus();4601+ else if (i < blocks.length - 1) { wxUI.at = i + 1; wxFocus(); }4602+ break;4603+ case 'ArrowLeft': if (isShown(wxWatch())) focusEl(wxWatch()); else openDock(); break;4604+ case 'ArrowRight': if (i < 0) wxFocus(); break;4605+ default: return;4606+ }4607+ e.preventDefault();4608+}4609+37274610 function renderWeather() {37284611 const v = $('#weather');4612+ wxWatch().hidden = !weatherChannel();37294613 const wx = state.wx;37304614 if (!wx) {37314615 f(v, 'now').innerHTML = '<dl class="rail-stats"><div><dt>Weather</dt><dd>Loading</dd></div></dl>';
@@ -3789,6 +4673,8 @@ function renderWeather() {
37894673 const bust = Math.floor(Date.now() / (4 * MIN));37904674 f(v, 'radar').src = `${state.boot.serverUrl}${wx.radar}?t=${bust}`;37914675 f(v, 'satellite').src = `${state.boot.serverUrl}${wx.satellite}?t=${bust}`;4676+ // An alert that went had the focus, perhaps.4677+ if (state.view === 'weather' && !v.contains(document.activeElement)) wxFocus();37924678 }37934679 37944680 // wxPrograms is the weather channel's guide: hour-long "Local Forecast" blocks
M frontend/dist/index.html +27 -11
@@ -28,6 +28,8 @@
2828 2929 <div id="entry" aria-live="assertive"></div>3030 31+ <!-- The channel banner, and with OK the on-screen controls: a timeline32+ and a row of buttons below it (app.js, on-screen controls). -->3133 <div id="banner" aria-live="polite">3234 <div class="bn-chan">3335 <div class="bn-num" data-f="num"></div>
@@ -50,6 +52,12 @@
5052 <div class="bn-tx" data-f="tx"></div>5153 <div class="bn-atsc3" data-f="atsc3"></div>5254 </div>55+ <div class="tl fx" data-f="timeline" tabindex="-1" role="slider" aria-label="Timeline" hidden>56+ <span class="tl-t" data-f="tl-lo"></span>57+ <div class="tl-bar"><i class="tl-past"></i><i class="tl-buf"></i><i class="tl-edge"></i><i class="tl-head"><b data-f="tl-at"></b></i></div>58+ <span class="tl-t" data-f="tl-hi"></span>59+ </div>60+ <div class="osd-bar" data-f="osd" role="toolbar" aria-label="Controls"></div>5361 </div>5462 5563 <!-- Guide -->
@@ -86,13 +94,14 @@
8694 <nav class="rail">8795 <div class="rail-previewing" data-f="previewing" hidden>8896 <dl class="rail-stats" data-f="pvplace"></dl>89- <button type="button" class="btn" data-act="copy">Copy summary</button>90- <button type="button" class="btn ghost" data-act="back">Back to my setup</button>97+ <button type="button" class="btn fx" data-act="copy">Copy summary</button>98+ <button type="button" class="btn ghost fx" data-act="back">Back to my setup</button>9199 </div>92100 <div class="rail-menu" data-f="presets" role="radiogroup" aria-label="Antenna"></div>93101 <dl class="rail-stats" data-f="stats"></dl>94102 <form class="rail-preview" data-f="preview" autocomplete="off">95- <label>Preview a ZIP code <input name="zip" inputmode="numeric" maxlength="5" placeholder="80302"></label>103+ <!-- OK on the label puts the cursor in the field (app.js). -->104+ <label class="fx" tabindex="-1">Preview a ZIP code <input name="zip" inputmode="numeric" maxlength="5" placeholder="80302"></label>96105 </form>97106 </nav>98107 <div class="main a-body">
@@ -121,16 +130,20 @@
121130 122131 <!-- Weather -->123132 <section id="weather" class="view railed" aria-label="Weather">124- <nav class="rail"><div class="wx-now" data-f="now"></div></nav>133+ <nav class="rail">134+ <div class="wx-now" data-f="now"></div>135+ <button type="button" class="btn ghost fx wx-watch" data-act="watch" hidden>Watch the weather channel</button>136+ </nav>137+ <!-- Each part takes the focus in turn, Up and Down (app.js, weather). -->125138 <div class="main wx-main">126- <div class="wx-alerts" data-f="alerts"></div>127- <div class="wx-block">139+ <div class="wx-alerts fx" data-f="alerts" tabindex="-1"></div>140+ <div class="wx-block fx" tabindex="-1">128141 <div class="wx-label">Next 24 hours</div>129142 <svg class="wx-chart" data-f="chart" viewBox="0 0 960 200" preserveAspectRatio="none"></svg>130143 <div class="wx-hours" data-f="hours"></div>131144 </div>132- <div class="wx-days" data-f="days"></div>133- <div class="wx-maps">145+ <div class="wx-days fx" data-f="days" tabindex="-1"></div>146+ <div class="wx-maps fx" tabindex="-1">134147 <figure><img data-f="radar" alt=""><figcaption>Radar, last hour</figcaption></figure>135148 <figure><img data-f="satellite" alt=""><figcaption>Satellite</figcaption></figure>136149 </div>
@@ -173,11 +186,14 @@
173186 <div class="boot-mark">Airwaves</div>174187 <div class="boot-sub" data-f="sub">Over-the-air television</div>175188 <ol class="boot-log"></ol>189+ <!-- The arrows move between the fields and Connect; OK on a field190+ puts the cursor in it (app.js, bootKey). -->176191 <form class="boot-connect" hidden autocomplete="off">177- <input name="server" placeholder="Server address" autocapitalize="off" spellcheck="false">178- <input name="token" type="password" placeholder="Token, if set">179- <button class="btn">Connect</button>192+ <label class="boot-field fx" tabindex="-1"><input name="server" placeholder="Server address" autocapitalize="off" spellcheck="false"></label>193+ <label class="boot-field fx" tabindex="-1"><input name="token" type="password" placeholder="Token, if set"></label>194+ <button class="btn fx">Connect</button>180195 </form>196+ <div class="boot-hint" data-f="hint"></div>181197 </div>182198 </div>183199
M frontend/dist/style.css +114 -22
@@ -130,7 +130,7 @@ body.mode-tv #entry.show { opacity: 1; transform: none; }
130130 131131 #banner {132132 position: fixed; z-index: 30; left: 28px; right: 28px; bottom: 26px;133- display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 30px;133+ display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 14px 30px;134134 padding: 24px 28px 22px;135135 background: linear-gradient(180deg, rgba(13,15,18,.86), rgba(7,8,10,.94));136136 backdrop-filter: blur(20px) saturate(1.2); -webkit-backdrop-filter: blur(20px) saturate(1.2);
@@ -209,7 +209,10 @@ body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings
209209 .tight .gd-title { -webkit-line-clamp: 1; font-size: clamp(30px, calc(5.2 * var(--vh)), 48px); }210210 .tight .gd-meta { margin: 6px 0; }211211 .tight .gd-desc { -webkit-line-clamp: 2; }212-.tight .gd-hint { display: none; }212+/* The program's picture makes way for its actions and the hint. */213+.tight .g-top { grid-template-columns: var(--pv-w) minmax(0, 1fr); }214+.tight .gd-art { display: none; }215+.tight .gd-desc, .tight .gd-hint { max-width: none; }213216 214217 .g-bar { height: 44px; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }215218 .g-filters { display: flex; gap: 2px; height: 100%; }
@@ -259,10 +262,8 @@ body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings
259262 .g-cell.past { opacity: .45; }260263 .g-cell.empty { background: transparent; box-shadow: inset 0 0 0 1px var(--line); }261264 .g-cell.empty .t { color: var(--faint); font-weight: 400; font-style: italic; }262-.g-cell.sel { background: var(--amber); box-shadow: none; opacity: 1; }263-.g-cell.sel .t, .g-cell.sel .s { color: var(--ink); }265+.g-cell.sel { background: var(--focus-fill); box-shadow: inset 0 0 0 1px var(--amber-2); opacity: 1; }264266 .g-cell .tag { color: var(--teal); }265-.g-cell.sel .tag { color: var(--ink); }266267 .g-now { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--amber); pointer-events: none; box-shadow: 0 0 14px rgba(255,178,36,.45); }267268 .g-now span { position: absolute; top: 0; left: -5px; border: 6px solid transparent; border-top: 8px solid var(--amber); }268269
@@ -324,7 +325,7 @@ body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings
324325 .a-table .r { text-align: right; } .a-table .c { text-align: center; }325326 .a-table tr { cursor: pointer; transition: background .15s; }326327 .a-table tbody tr:hover { background: var(--ink-2); }327-.a-table tr.sel { background: var(--ink-3); box-shadow: inset 3px 0 0 var(--amber); }328+.a-table tr.sel { background: var(--ink-3); box-shadow: inset 3px 0 0 var(--amber-2); }328329 .a-table tr.dim td { opacity: .42; }329330 .a-table .call { font: 600 18px var(--f-display); letter-spacing: .03em; }330331 .a-table .sub { font-size: 11px; color: var(--muted); }
@@ -348,7 +349,7 @@ body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings
348349 a controller: the focused row is filled and outlined in amber. */349350 #settings { grid-template-rows: minmax(0, 1fr); }350351 .s-main { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; max-width: 940px; }351-.s-list { overflow: auto; min-height: 0; padding: 2px 2px 48px; scrollbar-width: none; }352+.s-list { overflow: auto; min-height: 0; padding: 4px 2px 48px; scrollbar-width: none; }352353 .s-list::-webkit-scrollbar { display: none; }353354 .s-head { margin: 28px 0 10px 18px; font: 500 11px var(--f-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--amber); }354355 .s-head:first-child { margin-top: 2px; }
@@ -359,8 +360,7 @@ body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings
359360 scroll-margin: 48px 0 24px;360361 }361362 body.mouse-active .s-row:hover { background: var(--ink-3); } /* not under a resting pointer */362-.s-row.focus { background: #1f1d18; box-shadow: inset 0 0 0 2px var(--amber), inset 6px 0 0 var(--amber); }363-body.dock-nav .s-row.focus { box-shadow: inset 0 0 0 1px var(--line-2); background: var(--ink-3); }363+.s-row.focus { background: var(--ink-3); box-shadow: inset 0 0 0 1px var(--line-2); }364364 .s-label { min-width: 0; }365365 .s-label b { display: block; font: 500 17px/1.25 var(--f-body); color: var(--paper); }366366 .s-label small { display: block; margin-top: 3px; font-size: 13px; color: var(--muted); }
@@ -413,11 +413,12 @@ body:not(.has-dvr) #dock [data-view="recordings"] { display: none; }
413413 .glyph { height: 1.4em; width: auto; vertical-align: middle; margin: -.25em 0; opacity: .9; }414414 body.pad-input #dock kbd { padding: 0; box-shadow: none; }415415 #dock .glyph { height: 17px; margin: -3px 0; }416-#dock button.pick .glyph { filter: invert(1); }417416 /* Picked with the arrow keys, from a remote or a controller. */418417 body.dock-nav #dock { opacity: 1; transform: translate(-50%, 0); }419-#dock button.pick { background: var(--amber); color: var(--ink); }420-#dock button.pick kbd { color: var(--ink); box-shadow: inset 0 0 0 1px rgba(7,8,10,.35); }418+#dock button.pick kbd { color: var(--amber); }419+body.osd-open #dock { opacity: 1; transform: translate(-50%, 0); }420+/* Larger on a TV, where it's the way to every view. */421+body.android-tv #dock button { padding: 7px 15px 7px 11px; font-size: 13px; }421422 422423 #boot { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; background: radial-gradient(90% 70% at 50% 40%, #12151b, var(--ink) 70%); transition: opacity .9s .2s, visibility 0s 1.1s; }423424 body:not(.booting) #boot { opacity: 0; visibility: hidden; }
@@ -437,11 +438,13 @@ body:not(.booting) #boot { opacity: 0; visibility: hidden; }
437438 @keyframes rise { from { opacity: 0; transform: translateY(6px) } }438439 .boot-connect { display: grid; grid-template-columns: 2fr 1fr auto; gap: 10px; margin-top: 22px; }439440 .boot-connect[hidden] { display: none; }441+.boot-field { display: grid; border-radius: 3px; }440442 .boot-connect input {441- background: var(--ink); border: 1px solid var(--line-2); border-radius: 3px; padding: 12px 14px;443+ min-width: 0; background: var(--ink); border: 1px solid var(--line-2); border-radius: 3px; padding: 12px 14px;442444 font: 400 15px var(--f-mono); color: var(--paper); outline: none; -webkit-user-select: text; user-select: text;443445 }444446 .boot-connect input:focus { border-color: var(--amber); }447+.boot-hint { margin-top: 14px; font: 400 12px var(--f-mono); color: var(--faint); letter-spacing: .04em; min-height: 1.4em; }445448 /* In a browser the address is the page's own (web.js). */446449 .boot-connect input[readonly] { background: transparent; border-style: dashed; color: var(--muted); }447450
@@ -460,9 +463,9 @@ body:not(.booting) #boot { opacity: 0; visibility: hidden; }
460463 .gd-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 12px; }461464 .gd-actions:empty { display: none; }462465 .act {463- border: 0; cursor: pointer; padding: 7px 12px; border-radius: 3px; background: none;466+ border: 0; cursor: pointer; padding: 8px 13px; border-radius: 3px; background: none;464467 box-shadow: inset 0 0 0 1px var(--line-2); color: var(--paper);465- font: 500 11px var(--f-mono); letter-spacing: .1em; text-transform: uppercase;468+ font: 500 12px var(--f-mono); letter-spacing: .1em; text-transform: uppercase;466469 display: inline-flex; align-items: center; gap: 7px;467470 }468471 .act:hover { box-shadow: inset 0 0 0 1px var(--paper); }
@@ -475,13 +478,13 @@ body:not(.booting) #boot { opacity: 0; visibility: hidden; }
475478 .g-cell.rec-on::after { animation: pulse 1.4s ease-in-out infinite; }476479 body:not(.mode-guide) .g-cell.rec-on::after { animation: none; } /* hidden, so still */477480 .g-cell.rec-x::after { background: transparent; box-shadow: inset 0 0 0 1.5px var(--red); }478-.g-cell.sel.rec::after { box-shadow: 0 0 0 2px var(--amber); }481+.g-cell.sel.rec::after { box-shadow: 0 0 0 2px var(--focus-fill); }479482 @keyframes pulse { 50% { opacity: .35; } }480483 481-.r-main { display: grid; min-height: 0; }484+.r-main { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; }482485 .r-panel { display: none !important; min-height: 0; }483486 .r-panel.on { display: grid !important; }484-.r-lib.on { grid-template-rows: auto minmax(0, 1fr); gap: 24px; }487+.r-lib.on { grid-template-rows: auto minmax(0, 1fr); grid-template-columns: minmax(0, 1fr); gap: 24px; }485488 .r-detail { display: grid; grid-template-columns: minmax(0, 1fr) min(34%, 420px); gap: 30px; min-height: var(--pip-h); align-items: end; }486489 .r-detail .rd-title { font: 800 clamp(34px, calc(4 * var(--vw)), 54px)/.95 var(--f-display); margin: 6px 0 6px; }487490 .r-detail .rd-kicker { font: 500 12px var(--f-mono); letter-spacing: .12em; text-transform: uppercase; color: var(--amber); }
@@ -491,10 +494,10 @@ body:not(.mode-guide) .g-cell.rec-on::after { animation: none; } /* hidden, so s
491494 .r-detail .rd-art { align-self: stretch; aspect-ratio: 16 / 9; border-radius: 3px; background: var(--ink-2) center/cover no-repeat;492495 -webkit-mask-image: linear-gradient(90deg, transparent, #000 35%); mask-image: linear-gradient(90deg, transparent, #000 35%); }493496 .r-detail .rd-empty { font: 600 28px/1.15 var(--f-display); color: var(--muted); max-width: 30ch; }494-.r-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; align-content: start; overflow: auto; min-height: 0; padding: 4px; scrollbar-width: thin; }497+.r-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 18px; align-content: start; overflow: auto; min-height: 0; padding: 7px; scrollbar-width: thin; }495498 .r-card { background: var(--ink-2); border-radius: 3px; overflow: hidden; cursor: pointer; box-shadow: 0 0 0 1px var(--line); transition: box-shadow .15s, transform .15s; }496499 .r-card:hover { box-shadow: 0 0 0 1px var(--line-2); }497-.r-card.sel { box-shadow: 0 0 0 2px var(--amber); transform: translateY(-2px); }500+.r-card.sel { box-shadow: 0 0 0 1px var(--amber-2); }498501 .r-card.failed { opacity: .55; }499502 .r-art { aspect-ratio: 16 / 9; background: linear-gradient(135deg, #2a2219, #0d0f12) center/cover no-repeat; display: grid; place-items: end start; }500503 .r-art span { padding: 12px 14px; font: 800 26px/.95 var(--f-display); color: var(--amber); }
@@ -508,7 +511,7 @@ body:not(.mode-guide) .g-cell.rec-on::after { animation: none; } /* hidden, so s
508511 .r-list { list-style: none; margin: 0; padding: 0; gap: 2px; align-content: start; overflow: auto; max-width: 900px; scrollbar-width: thin; }509512 .r-item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 14px 16px; border-radius: 3px; cursor: pointer; }510513 .r-item:hover { background: var(--ink-2); }511-.r-item.sel { background: var(--ink-3); box-shadow: inset 3px 0 0 var(--amber); }514+.r-item.sel { background: var(--ink-3); box-shadow: inset 3px 0 0 var(--amber-2); }512515 .r-item .r-name { font-size: 16px; }513516 .r-item .r-time { font: 400 11px var(--f-mono); color: var(--muted); text-align: right; min-width: 64px; }514517 .r-item .r-time b { display: block; font: 600 18px var(--f-display); color: var(--paper); letter-spacing: .02em; }
@@ -646,7 +649,96 @@ body.lite #stage { box-shadow: 0 0 0 1px var(--line-2); }
646649 body.lite .g-now, body.lite #toast { box-shadow: none; }647650 body.lite #toast { border-left: 3px solid var(--amber); }648651 body.lite #stage.tuning #static, body.lite #entry::after, body.lite .boot-mark, body.lite .boot-log li,649-body.lite .g-cell.rec-on::after { animation: none !important; }652+body.lite .g-cell.rec-on::after, body.lite .ob svg { animation: none !important; }650653 body.lite .boot-mark { background: none; color: var(--paper); }651654 /* The spinner turns in eight steps: eight redraws a second, not sixty. */652655 body.lite .ps-spin { animation-timing-function: steps(8); }656+657+/* ---------- On-screen controls ---------- */658+/* OK on live TV (app.js, on-screen controls): the banner grows a timeline659+ and a row of buttons, and the dock shows above as the way to the views. */660+#banner .tl, #banner .osd-bar { grid-column: 1 / -1; }661+#banner:not(.osd) .tl, #banner:not(.osd) .osd-bar { display: none; }662+#banner.osd.tl-on .bn-progress { display: none; }663+#banner.osd .bn-desc { -webkit-line-clamp: 1; }664+#banner.osd .bn-next { margin-top: 8px; }665+/* The timeline: the program from its start to its end, what's aired of it,666+ what can be rewound (amber), the live edge (red) and where the picture667+ is. Left and Right skip while it has the focus. */668+.tl { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 18px; margin: 0 -12px; padding: 12px; border-radius: 3px; cursor: pointer; }669+.tl-t { font: 500 13px var(--f-mono); color: var(--muted); letter-spacing: .04em; min-width: 4.6em; white-space: nowrap; }670+.tl-t:last-child { text-align: right; }671+.tl-bar { position: relative; height: 6px; border-radius: 3px; background: var(--line-2); }672+.tl-bar i { position: absolute; top: 0; bottom: 0; left: 0; display: block; border-radius: 3px; }673+.tl-past { background: rgba(237, 230, 214, .2); }674+.tl-buf { background: var(--amber-2); }675+.tl-edge { width: 3px; margin-left: -1px; top: -5px !important; bottom: -5px !important; background: var(--red); border-radius: 1px !important; }676+.tl-head { width: 14px; height: 14px; top: 50% !important; margin: -7px 0 0 -7px; border-radius: 50% !important; background: var(--paper); box-shadow: 0 0 0 3px rgba(7, 8, 10, .75); }677+.tl-head b { display: none; position: absolute; bottom: 22px; left: 50%; transform: translateX(-50%); padding: 4px 7px; border-radius: 2px; background: var(--ink); box-shadow: inset 0 0 0 1px var(--amber-2); font: 500 13px/1 var(--f-mono); color: var(--paper); white-space: nowrap; }678+.tl:focus .tl-head { width: 20px; height: 20px; margin: -10px 0 0 -10px; background: var(--amber); }679+.tl:focus .tl-head b:not(:empty) { display: block; }680+.tl:focus .tl-bar { height: 8px; }681+/* The buttons: an icon over a label each. */682+.osd-bar { display: flex; gap: 6px; padding-top: 14px; border-top: 1px solid var(--line); }683+.ob {684+ flex: 0 1 118px; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 8px;685+ padding: 11px 6px 10px; border: 0; border-radius: 3px; background: none; color: var(--muted); cursor: pointer;686+}687+.ob svg { width: 28px; height: 28px; fill: currentColor; flex: none; }688+.ob span { max-width: 100%; font: 500 12px var(--f-mono); letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }689+body.mouse-active .ob:hover { color: var(--paper); background: var(--ink-3); }690+.ob.on svg { color: var(--amber); }691+.ob[data-act="record"] svg, .ob[data-act="live"].at svg { color: var(--red); }692+.ob[data-act="record"].on svg { animation: pulse 1.4s ease-in-out infinite; }693+694+/* ---------- Focus ---------- */695+/* One mark for wherever the remote, a controller or the arrow keys are, in696+ every view: an amber ring, a warm fill and a slight lift (Android TV's697+ focus system: an outline and a raise). Buttons, fields and blocks take698+ the page's focus (.fx, only ever from keys); the guide's grid, the lists699+ and the dock mark their pick with a class, only while their part of the700+ view has the focus (data-zone in app.js). The ring is drawn inside where701+ a scrolling box would clip it. */702+:root {703+ --ring: 0 0 0 3px var(--amber);704+ --ring-in: inset 0 0 0 3px var(--amber);705+ --focus-fill: #2a2216;706+ --lift: translateY(-2px);707+ --glow: 0 12px 28px rgba(0, 0, 0, .55);708+}709+body.lite { --glow: 0 0 0 0 transparent; }710+.fx { outline: none; }711+.fx:focus, #dock button.pick {712+ box-shadow: var(--ring), var(--glow); background-color: var(--focus-fill); color: var(--paper); transform: var(--lift);713+}714+/* A filled button keeps its fill, ringed apart from it. */715+.btn.fx:not(.ghost):focus { background-color: var(--amber); color: var(--ink); box-shadow: 0 0 0 2px var(--ink), 0 0 0 5px var(--amber), var(--glow); }716+.wx-main .fx:focus, .rail-preview label.fx:focus { box-shadow: var(--ring-in), var(--glow); }717+body:not(.dock-nav) #guide[data-zone="grid"] .g-cell.sel,718+body:not(.dock-nav) .s-row.focus,719+body:not(.dock-nav) #recordings[data-zone="list"] .r-item.sel,720+body:not(.dock-nav) #antenna[data-zone="table"] .a-table tr.sel {721+ box-shadow: var(--ring-in), var(--glow); background: var(--focus-fill); transform: var(--lift);722+}723+body:not(.dock-nav) #recordings[data-zone="list"] .r-card.sel { box-shadow: var(--ring), var(--glow); transform: var(--lift); }724+.g-cell.sel { z-index: 1; }725+.s-row.focus .s-value { color: var(--paper); }726+/* Where the focus goes back to when it's elsewhere in the view. */727+.rm-item.fx:focus { box-shadow: var(--ring-in), var(--glow); }728+.g-filters button.fx:focus { box-shadow: var(--ring-in); color: var(--amber); }729+.rail-preview label.fx { margin: -8px -10px; padding: 8px 10px; border-radius: 3px; }730+.wx-main > .fx { border-radius: 3px; }731+.wx-main .wx-block.fx, .wx-main .wx-days.fx, .wx-main .wx-maps.fx { padding: 10px 12px; margin: -10px -12px; }732+.wx-main { padding: 12px 20px 12px 14px; }733+.wx-watch { margin: 0 14px; justify-self: stretch; text-align: center; }734+735+/* ---------- Actions on a selected item ---------- */736+/* Recordings: the selected recording's buttons, and a list item's own. */737+.rd-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }738+.rd-actions:empty { display: none; }739+.r-acts { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; }740+.act.armed { box-shadow: inset 0 0 0 1px var(--red); color: var(--red); }741+.act.fx.armed:focus { box-shadow: var(--ring), var(--glow); color: var(--red); }742+/* A field being typed in has the ring, not its row. */743+.boot-connect input:focus, .rail-preview input:focus, .s-row input:focus { box-shadow: var(--ring); border-color: var(--amber); }744+body:not(.dock-nav) .s-row.focus:focus-within { box-shadow: inset 0 0 0 1px var(--line-2); transform: none; }