clone

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

Fold Reception into Settings' tabs behind More, and keep desktop, console and TV apart

FelineStateMachine committed cbf3d15
The top bar is TV, Guide, Recordings, Weather and More. More is Settings
with a tab for each area in the rail, Reception among them: Up and Down
pick a tab, OK goes in, Back comes out, as on Android TV.

The app is three experiences, chosen once (platform, body.pf-*): a
desktop's mouse and keyboard, a console's controller, a TV's remote,
with Settings, Display, Layout to change it on a device. What used to
ask whether this was the Android TV app now asks what's in hand.

While a stream is open the app says so every 30 s, and starts one the
server stopped again. A failed recording puts a count on Recordings.
commit
cbf3d1504187045f50d48d207adf5bd91480e7d6
parent
09a035da3052c3bbe8805b6fa14c95c413c9cc3d
author
FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
kind
feat risk score 2.3 of 3, Jev's estimate
5 files changed, +425 -172
MREADME.md37++-
Mfrontend/dist/app.js438++++++++++++++++++++++--------
Mfrontend/dist/index.html75+++---
Mfrontend/dist/style.css38++-
Mfrontend/dist/web.js9+-
M README.md +32 -5
@@ -61,7 +61,7 @@ path for recordings, default `~/airwaves/recordings`); `MUSIC` and `CHANNELS` (h
6161 folders for the custom channels); `TZ`. Location is deployment6262 configuration only; the app never shows or edits it.6363 64-In the app, open Settings and set **Airwaves server** to the server's name, `nas`64+In the app, open More, the Server tab, and set **Address** to the server's name, `nas`6565 in these examples.6666 6767 **The TV in a browser**: `airwavesd` also serves the app itself at `/tv/`, for example
@@ -91,7 +91,7 @@ HDHomeRun's scan found; the FCC's records, the Gracenote listings and the ATSC 3
9191 only describe them (call sign, network, transmitter, listings), never add one. Without a9292 tuner there are no antenna channels. A channel that won't lock right now stays listed,9393 and tuning it says so: "No signal on RF 31 (KTVD 20.x) right now". The HDHomeRun scans94-when it powers up; **Scan for channels** in Reception (`POST /api/tuner/scan`) scans94+when it powers up; **Scan for channels** in Settings, Reception (`POST /api/tuner/scan`) scans9595 again, after moving the antenna, taking every tuner for the few minutes it runs.9696 9797 **Signal is measured, never estimated.** `airwavesd` records, per RF channel, readings
@@ -118,6 +118,11 @@ gap, not an end: it tries again until its time is up, and after a restart a reco
118118 whose time isn't up carries on. A finished recording says what it's missing: "Began 12119119 minutes into the program", "About 3 minutes are missing in between".120120 121+`dvr.json`, `signal.json` and `config.json` are written whole or not at all, each122+keeping its last version as `.bak` and a copy a day in `<data>/backups` for a week. One123+that won't read is set aside as `.corrupt-<time>` and the newest good copy takes its124+place, so a crash or a bad write loses at most the last change.125+121126 ## Without an antenna122127 123128 `airwavesd` also runs on its own for the custom channels only (Jellyfin, YouTube,
@@ -160,16 +165,35 @@ had.
160165 | --- | --- |161166 | G | Guide: arrows move, and Up and Down wrap round the channels. The filters (Favorites, Sports...; also `[` `]`) are Down from the top bar, which is Left from the earliest program. Enter watches what's on, or shows a program to come's actions; hold Enter, or I, for any program's: watch, record (which opens to this one, every episode or new only), favorite, hide. Home jumps to now, R / Shift R / N record, Shift F favorite, H hide (U undoes) |162167 | D | Recordings: Left (or `[` `]`) for Library, Coming up, Series. Enter shows a recording's actions: resume, from the start, watched, delete (press twice); for one recording now, watch from the start, 30 more minutes, stop (keeping it) and delete; Shift Enter starts over, W marks watched, Delete twice removes; on a series K sets how many to keep, N new episodes only |163-| A | Reception: what the tuners measured, by RF channel (the ones the scan found first), with the transmitters licensed on each. Up and Down pick one, Enter watches its first channel; Left for the tuner, Measure now, which reads every RF channel on a free tuner and shows its progress, and Scan for channels (press twice) |164168 | 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 |165-| `,` or S | Settings: server, guide hours, deleting watched recordings, hidden channels, the tuner |169+| `,` or S | More: Settings, a tab for each area in the rail (Display, Watching, Channels, Reception, Recordings, Server, About). Up and Down pick a tab, Enter goes in, Back returns to the tabs; Page Up and Down (a controller's LB and RB, also `[` `]`) change tab |170+| A | Settings' Reception tab: what the tuners measured, by RF channel (the ones the scan found first), with the transmitters licensed on each. Up and Down pick one, Enter watches its first channel; above them the tuner, Measure now, which reads every RF channel on a free tuner and shows its progress, and Scan for channels (press twice) |166171 | Esc | Back to TV; from a recording, back to live |167172 173+A recording that fails puts a count on Recordings in the top bar, and says so once,174+until Recordings is opened.175+168176 With only a remote's arrows, OK and Back (an Android TV remote), every view is169177 reached from the views above it: Up from a view's top, or Left from its left170178 edge, and on TV, Up through the on-screen controls. Whatever the arrows are on has171179 an amber ring.172180 181+### Desktop, console and TV182+183+The app is three experiences, kept apart in the code (`platform` in app.js, `pf-*`184+classes in style.css):185+186+| | Where | In hand | Hints |187+| --- | --- | --- | --- |188+| Desktop | The app in a window on a Mac, Linux or Windows, or a browser | Mouse and keyboard; letters are shortcuts | Keys |189+| Console | The Linux app under gamescope (SteamOS Game Mode) | A controller | Its buttons, from the start |190+| TV | The Android TV app | A remote: arrows, OK, Back and media keys | The remote's keys; hold OK for more |191+192+The app says which it is (`AIRWAVES_PLATFORM=desktop`, `console` or `tv` overrides the193+desktop app's guess; `?platform=` does the same at `/tv/`), and Settings, Display, Layout194+can change it on one device. Console and TV start at 125%, sized for across a room. A195+controller or a keyboard used on any of them still brings its own hints.196+173197 ## Data sources174198 175199 | Source | Used for | Cache |
@@ -617,7 +641,10 @@ refused, and the Jellyfin account isn't among the tools.
617641 618642 Live channels are read from the HDHomeRun, and recordings from their files, as MPEG-TS619643 and transcoded by `airwavesd` to H.264 and AAC in HLS, which the app plays natively. Custom channels come620-from `airwavesd` itself and play the same way. ATSC 1.0 (MPEG-2 video, AC-3 audio) plays644+from `airwavesd` itself and play the same way. While a stream is open, playing or paused,645+the app says so every 30 seconds (`POST /api/heartbeat`); `airwavesd` stops a stream646+nobody has fetched from or vouched for in 2 minutes, which frees its tuner when an app647+goes away without saying so. ATSC 1.0 (MPEG-2 video, AC-3 audio) plays621648 fully; ATSC 3.0 is out of scope (AC-4 audio and A3SA DRM).622649 623650 ## Layout
M frontend/dist/app.js +325 -113
@@ -122,11 +122,49 @@ function setLite(on) {
122122 setLite(/Linux/.test(navigator.userAgent) && !/Android/.test(navigator.userAgent));123123 124124 // The Android TV app is a WebView whose user agent ends in125-// AirwavesTV/<version>, showing this page from the server (web.js): a TV126-// with a remote, which has arrows, OK, Back and media keys but no letters127-// or pointer.125+// AirwavesTV/<version>, showing this page from the server (web.js). This126+// says only what's the WebView's own (its keyboard, the Home key); what a127+// TV is like is the platform's.128128 const androidTV = /\bAirwavesTV\//.test(navigator.userAgent);129-document.body.classList.toggle('android-tv', androidTV);129+130+// ---------- platform ----------131+// Airwaves is three experiences, each whole and kept apart (body.pf-*,132+// and the checks of platform):133+// desktop: the app in a window on a Mac, Linux or Windows, or a browser.134+// A mouse and a keyboard: hints name keys, letters are shortcuts, the135+// dock shows with the pointer.136+// console: the Linux app under gamescope, SteamOS's Game Mode on a TV.137+// A controller: hints show its buttons from the start, sized for138+// across a room.139+// tv: the Android TV app. A remote with arrows, OK, Back and media keys140+// but no letters or pointer: hints name the remote's keys, holding OK141+// offers more, and the on-screen controls lead to everything.142+// The app says which it is (Boot's platform: AIRWAVES_PLATFORM, else a143+// console's under gamescope; web.js: the Android TV app's TV, else a144+// desktop's, or ?platform=), and Settings, Display, Layout can say145+// otherwise on this device. What's pressed still counts: a controller on a146+// desktop brings its buttons to the hints, a keyboard on a TV its keys.147+const PLATFORMS = [['desktop', 'Desktop'], ['console', 'Console'], ['tv', 'TV']];148+const platformKey = 'airwaves.platform';149+let platform = 'desktop';150+let detectedPlatform = 'desktop';151+// onRemote: the hints and the ways to do things are a TV remote's.152+const onRemote = () => platform === 'tv' && !prompts.pad;153+// onKeys: they're a keyboard's, letters and all.154+const onKeys = () => platform !== 'tv' && !prompts.pad;155+156+function applyPlatform(detected) {157+ if (detected) detectedPlatform = PLATFORMS.some(([k]) => k === detected) ? detected : 'desktop';158+ const forced = localStorage.getItem(platformKey) || '';159+ const next = PLATFORMS.some(([k]) => k === forced) ? forced : detectedPlatform;160+ for (const [k] of PLATFORMS) document.body.classList.toggle(`pf-${k}`, k === next);161+ const changed = next !== platform;162+ platform = next;163+ // A console starts with its controller's buttons in the hints.164+ if (platform === 'console') setPrompts(true);165+ else if (changed) setPrompts(false);166+ return changed;167+}130168 131169 // ---------- state ----------132170 const state = {
@@ -415,7 +453,14 @@ async function init() {
415453 } else if (view) setView(view);416454 setInterval(tick, 30_000);417455 setInterval(() => scan(false).then(renderAll).catch((e) => log('warn', e)), 30 * MIN);418- setInterval(() => { if (state.view === 'guide' || state.view === 'recordings') loadDVR(); }, MIN);456+ let dvrAt = Date.now();457+ setInterval(() => {458+ if (state.view === 'guide' || state.view === 'recordings' || Date.now() - dvrAt > 5 * MIN - 1000) {459+ dvrAt = Date.now();460+ loadDVR();461+ }462+ }, MIN);463+ setInterval(heartbeat, 30_000);419464 setInterval(loadWeather, 2 * MIN);420465 setInterval(signalTick, 2000);421466 loadDVR();
@@ -454,10 +499,10 @@ function applyBoot(b) {
454499 state.info = b.info || { name: '', tuner: {}, dvr: false };455500 state.config = b.config || {};456501 document.body.classList.toggle('has-dvr', !!state.info.dvr);457- // A server without an antenna (custom channels only) has no reception.458- $('#dock [data-view="antenna"]').style.display = state.info.antenna === false ? 'none' : '';459502 if (b.lite === '1' || b.lite === '0') setLite(b.lite === '1');503+ applyPlatform(b.platform || (androidTV ? 'tv' : 'desktop'));460504 applyScale();505+ renderHints();461506 }462507 463508 // ---------- recordings ----------
@@ -471,7 +516,45 @@ async function loadDVR() {
471516 }472517 state.dvrKeys = new Map((state.dvr.upcoming || []).map((it) => [it.key, it]));473518 if (state.view === 'guide') renderGuide();474- if (state.view === 'recordings') renderRecordings();519+ if (state.view === 'recordings') {520+ renderRecordings();521+ seeFailed();522+ }523+ renderFailed();524+}525+526+// ---------- failed recordings ----------527+// A recording that failed puts a count on Recordings in the dock, and says528+// so once, until Recordings is opened.529+const failedSeenKey = 'airwaves.failedSeen';530+const failedTold = new Set();531+const failedRecordings = () => ((state.dvr && state.dvr.recorded) || []).filter((it) => it.status === 'failed');532+533+function renderFailed() {534+ const b = $('#dock [data-view="recordings"]');535+ const seen = new Set(JSON.parse(localStorage.getItem(failedSeenKey) || '[]'));536+ const unseen = failedRecordings().filter((it) => !seen.has(it.id));537+ let badge = $('.badge', b);538+ if (!unseen.length) {539+ if (badge) badge.remove();540+ return;541+ }542+ if (!badge) {543+ badge = document.createElement('span');544+ badge.className = 'badge';545+ b.append(badge);546+ }547+ badge.textContent = String(unseen.length);548+ badge.title = `${unseen.length} recording${unseen.length > 1 ? 's' : ''} failed`;549+ const news = unseen.filter((it) => !failedTold.has(it.id));550+ for (const it of news) failedTold.add(it.id);551+ if (news.length === 1) toast(`Recording failed: ${news[0].title}${news[0].detail ? `. ${news[0].detail}` : ''}`, 7000);552+ else if (news.length) toast(`${news.length} recordings failed. Recordings has them.`, 7000);553+}554+555+function seeFailed() {556+ localStorage.setItem(failedSeenKey, JSON.stringify(failedRecordings().map((it) => it.id)));557+ renderFailed();475558 }476559 477560 const airingKey = (ch, p) => `${ch.number}@${Math.floor(p._s / 1000)}`;
@@ -524,8 +607,9 @@ function setView(v) {
524607 video.muted = !!state.userMuted;525608 if (!(state.current && state.current.weather)) showWX(false);526609 }527- if (v === 'antenna') openAntenna();528610 if (v === 'settings') openSettings();611+ if (v === 'tv' && state.staleStream) restartStream();612+ if (v === 'recordings') seeFailed();529613 return undefined;530614 }531615
@@ -576,6 +660,7 @@ async function tune(ch, { quiet = false, at = performance.now() } = {}) {
576660 state.current = ch;577661 state.note = '';578662 state.recording = null;663+ state.staleStream = false;579664 const token = ++state.tuneToken;580665 if (!quiet) showBanner(); else renderBanner();581666 stage.classList.add('tuning');
@@ -1282,7 +1367,11 @@ function wireKeys() {
12821367 return hideBanner();12831368 }12841369 if (k === 'g' || k === 'G') return setView(state.view === 'guide' ? 'tv' : 'guide');1285- if ((k === 'a' || k === 'A') && state.info.antenna !== false) return setView(state.view === 'antenna' ? 'tv' : 'antenna');1370+ if ((k === 'a' || k === 'A') && state.info.antenna !== false) {1371+ if (receptionShown()) return setView('tv');1372+ if (state.view !== 'settings') setView('settings');1373+ return openSettings('reception', 'list');1374+ }12861375 if ((k === 'd' || k === 'D') && state.info.dvr) return setView(state.view === 'recordings' ? 'tv' : 'recordings');12871376 if ((k === 'w' || k === 'W') && state.view !== 'recordings') return setView(state.view === 'weather' ? 'tv' : 'weather');12881377 if (k === ',' || k === 's' || k === 'S') return setView(state.view === 'settings' ? 'tv' : 'settings');
@@ -1302,7 +1391,6 @@ function wireKeys() {
13021391 if (k === 'f') return toggleFullscreen();13031392 13041393 if (state.view === 'guide') return guideKey(e);1305- if (state.view === 'antenna') return antennaKey(e);13061394 if (state.view === 'recordings') return recordingsKey(e);13071395 if (state.view === 'weather') return weatherKey(e);13081396 if (state.view !== 'tv') return;
@@ -1549,7 +1637,7 @@ function restoreFocus() {
15491637 if (state.view === 'tv' && osd.open) osdFocus(osd.last);15501638 else if (state.view === 'guide') guideZone(guideUI.zone);15511639 else if (state.view === 'recordings') recZone(recUI.zone);1552- else if (state.view === 'antenna') antZone(antUI.zone);1640+ else if (state.view === 'settings') settingsRefocus();15531641 else if (state.view === 'weather') wxFocus();15541642 }15551643
@@ -1560,6 +1648,8 @@ function viewBack() {
15601648 if (state.view === 'guide' && state.recPick) { closeRecordPick(); return true; }15611649 if (state.view === 'guide' && guideUI.zone !== 'grid') { guideZone('grid'); return true; }15621650 if (state.view === 'recordings' && recUI.zone === 'actions') { recZone('list'); return true; }1651+ // In a tab of Settings, Back goes to the tabs, as on Android TV.1652+ if (state.view === 'settings' && settingsUI.zone === 'list') { settingsZone('tabs'); return true; }15631653 return false;15641654 }15651655
@@ -1757,7 +1847,7 @@ const REMOTE_KEYS = { Enter: 'OK', Escape: 'Back', ' ': 'Play/Pause', Arrows: 'A
17571847 // controller or the remote has no such button.17581848 const keyHint = (key, label) => {17591849 if (prompts.pad) return glyph(key, label) || null;1760- if (androidTV) return REMOTE_KEYS[key] ? esc(REMOTE_KEYS[key]) : null;1850+ if (platform === 'tv') return REMOTE_KEYS[key] ? esc(REMOTE_KEYS[key]) : null;17611851 return esc(label);17621852 };17631853
@@ -2138,7 +2228,7 @@ function renderGuide() {
21382228 21392229 // guideStatusHints names the guide's keys beside its filters.21402230 function guideStatusHints() {2141- if (androidTV && !prompts.pad) return 'Filters: Left to the top bar, then Down';2231+ if (onRemote()) return 'Filters: Left to the top bar, then Down';21422232 return hints([['i', 'I', 'more'], ['', 'Shift F', 'favorite'], ['h', 'H', 'hide'], ['[', '[ ]', 'filter']]);21432233 }21442234
@@ -2314,7 +2404,7 @@ function renderGuideDetail(ch, p) {
23142404 const ok = keyHint('Enter', 'Enter');23152405 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`;23162406 const more = !live ? '' : prompts.pad ? hints([['i', 'I', 'for more']]) : `hold ${ok} for more`;2317- const rec = state.info.dvr && p && p._e > now && !ch.weather && !ch.custom && !prompts.pad && !androidTV ? ' | R record, Shift R series, N new only' : '';2407+ const rec = state.info.dvr && p && p._e > now && !ch.weather && !ch.custom && onKeys() ? ' | R record, Shift R series, N new only' : '';23182408 f(g, 'hint').innerHTML = `${when}${more ? `, ${more}` : ''}${rec}`;23192409 renderGuideActions(ch, p);23202410 const art = f(g, 'art');
@@ -2344,7 +2434,7 @@ function applySignal(sig) {
23442434 }23452435 state.sigStamp++;23462436 const sw = state.antenna.sweep;2347- if (was && was.running && !sw.running && state.view === 'antenna') {2437+ if (was && was.running && !sw.running && receptionShown()) {23482438 toast(sw.note ? `Measure now ${sw.note}` : `Measured ${sw.done} RF channels`, 4000);23492439 }23502440 // A channel scan just finished: its channels are the lineup now.
@@ -2372,7 +2462,7 @@ async function loadSignal() {
23722462 signalBusy = false;23732463 signalAt = Date.now();23742464 }2375- if (state.view === 'antenna') renderAntenna();2465+ if (receptionShown()) renderAntenna();23762466 if (state.view === 'guide') renderGuide();23772467 if (state.view === 'tv' && $('#banner').classList.contains('show')) renderBanner();23782468 }
@@ -2384,7 +2474,7 @@ function signalTick() {
23842474 if (!state.info || state.info.antenna === false || document.hidden) return;23852475 const running = !!(state.antenna && (state.antenna.sweep.running || state.antenna.tuner.scanning));23862476 const banner = state.view === 'tv' && state.current && !state.current.own && $('#banner').classList.contains('show');2387- const every = running ? 2000 : state.view === 'antenna' || banner ? 10_000 : state.view === 'guide' ? 30_000 : 0;2477+ const every = running ? 2000 : receptionShown() || banner ? 10_000 : state.view === 'guide' ? 30_000 : 0;23882478 if (every && Date.now() - signalAt >= every - 100) loadSignal();23892479 }23902480
@@ -2456,84 +2546,79 @@ function wireAntenna() {
24562546 }24572547 24582548 // Reception's parts take the focus in turn (data-zone): the RF channels'2459-// table, and the rail beside it, Left from the table: the tuner's card and2460-// Measure now. OK on an RF channel with channels watches the first.2549+// table, and above it a row of the tuner's card, Measure now and Scan. OK2550+// on an RF channel with channels watches the first. Left from either goes2551+// back to Settings' tabs.24612552 const antUI = { zone: 'table', key: 'measure' };24622553 2463-function openAntenna() {2464- renderAntenna();2465- signalAt = 0;2466- antZone(antRows().length ? 'table' : 'rail');2467-}2468-2469-const antRail = () => $$('#antenna .rail .fx').filter(isShown);2554+const antHead = () => $$('#antenna .a-head .fx').filter(isShown);24702555 24712556 function antZone(zone, key = '') {24722557 antUI.zone = zone;24732558 $('#antenna').dataset.zone = zone;24742559 if (key) antUI.key = key;2475- if (state.dock >= 0 || state.view !== 'antenna') return;2560+ if (state.dock >= 0 || !receptionShown() || settingsUI.zone !== 'list') return;24762561 renderRFDetail(); // its OK hint is the table's24772562 if (zone === 'table') {24782563 blurIn($('#antenna'));24792564 scrollRF();24802565 return;24812566 }2482- const items = antRail();2483- focusEl(items.find((el) => el.dataset.act === antUI.key) || items[items.length - 1]);2567+ const items = antHead();2568+ focusEl(items.find((el) => el.dataset.act === antUI.key) || items[0]);24842569 }24852570 24862571 function antRefocus() {2487- if (state.view !== 'antenna' || state.dock >= 0 || antUI.zone !== 'rail') return;2572+ if (!receptionShown() || state.dock >= 0 || settingsUI.zone !== 'list' || antUI.zone !== 'head') return;24882573 const el = document.activeElement;2489- if (!$('#antenna').contains(el) || !isShown(el)) antZone('rail');2574+ if (!$('#antenna').contains(el) || !isShown(el)) antZone('head');24902575 }24912576 2577+// antennaKey handles a key in Reception, reporting whether it did.24922578 function antennaKey(e) {2493- if (antUI.zone === 'rail') {2494- const items = antRail();2579+ if (antUI.zone === 'head') {2580+ const items = antHead();24952581 const el = document.activeElement;24962582 const i = items.indexOf(el);24972583 switch (e.key) {2498- case 'ArrowUp': if (i > 0) focusEl(items[i - 1]); else openDock(); break;2499- case 'ArrowDown': if (i < 0) antZone('rail'); else if (i < items.length - 1) focusEl(items[i + 1]); break;2500- case 'ArrowRight': if (antRows().length) antZone('table'); break;2501- case 'ArrowLeft': openDock(); break;2584+ case 'ArrowUp': openDock(); break;2585+ case 'ArrowLeft': if (i > 0) focusEl(items[i - 1]); else settingsZone('tabs'); break;2586+ case 'ArrowRight': if (i < 0) antZone('head'); else if (i < items.length - 1) focusEl(items[i + 1]); break;2587+ case 'ArrowDown': if (antRows().length) antZone('table'); break;25022588 case 'Enter':25032589 if (el && el.dataset.act === 'measure') measureNow();25042590 else if (el && el.dataset.act === 'scan') scanChannels();2505- else if (i < 0) antZone('rail');2591+ else if (i < 0) antZone('head');25062592 break;2507- default: return;2593+ default: return false;25082594 }25092595 // Where the focus comes back to, from the dock.25102596 const now = document.activeElement;25112597 if (now && now.dataset.act && $('#antenna').contains(now)) antUI.key = now.dataset.act;25122598 e.preventDefault();2513- return;2599+ return true;25142600 }25152601 const rows = antRows();25162602 let i = rows.indexOf(state.aRF);25172603 switch (e.key) {25182604 case 'ArrowUp':2519- if (i <= 0) { e.preventDefault(); openDock(); return; }2605+ if (i <= 0) { e.preventDefault(); antZone('head'); return true; }25202606 i--;25212607 break;25222608 case 'ArrowDown': i = Math.min(rows.length - 1, i + 1); break;2523- case 'PageDown': i = Math.min(rows.length - 1, i + 8); break;2524- case 'PageUp': i = Math.max(0, i - 8); break;2525- case 'ArrowLeft': e.preventDefault(); antZone('rail'); return;2609+ case 'ArrowLeft': e.preventDefault(); settingsZone('tabs'); return true;25262610 case 'Enter': {25272611 e.preventDefault();25282612 const m = state.byRF.get(state.aRF);25292613 const ch = m && m.channels.length && findChannel(m.channels[0]);25302614 if (ch) watchChannel(ch);2531- return;2615+ return true;25322616 }2533- default: return;2617+ default: return false;25342618 }25352619 e.preventDefault();25362620 pickRF(rows[Math.max(0, i)]);2621+ return true;25372622 }25382623 25392624 // antMuxes is the RF channels to list: those the scan found (by RF), then
@@ -2817,14 +2902,22 @@ function renderNextGen() {
28172902 }28182903 28192904 // ---------- settings ----------2820-// Settings is one list of rows by section, for a remote or a controller2821-// first (a mouse and a keyboard work too): Up and Down move, Left and Right2905+// More is Settings: a tab for each area in the rail, and beside it the2906+// tab's rows, or Reception's measurements. For a remote or a controller2907+// first (a mouse and a keyboard work too). On the tabs Up and Down pick2908+// one, Right or OK goes into it. In a list Up and Down move, Left and Right28222909 // change a choice or a switch, Enter switches, runs or opens a list (hidden2823-// channels, the controls, data sources), Esc goes back. Up from the first2824-// row, or Left on a row with nothing to change, moves to the dock. Changes2825-// are saved as they're made. The server's address and token need a2826-// keyboard: Enter on their row puts the cursor in them.2827-const settingsUI = { at: 0, sub: null, from: 0, rows: [], sections: [], pending: {}, timers: {}, tested: '' };2910+// channels, the controls, data sources), and Left on a row with nothing to2911+// change goes back to the tabs. Page Up and Down (a controller's LB and2912+// RB) change tab anywhere, Up from the top moves to the dock, and Esc goes2913+// back. Changes are saved as they're made. The server's address and token2914+// need a keyboard: Enter on their row puts the cursor in them.2915+const settingsUI = { tab: 'display', zone: 'tabs', at: 0, sub: null, from: 0, rows: [], pending: {}, timers: {}, tested: '' };2916+2917+// SETTINGS_TABS are the areas in the rail's order, [id, label]; a row's2918+// section is its tab's label.2919+const SETTINGS_TABS = [['display', 'Display'], ['watching', 'Watching'], ['channels', 'Channels'], ['reception', 'Reception'],2920+ ['recordings', 'Recordings'], ['server', 'Server'], ['about', 'About']];28282921 28292922 const AUDIO_LANGS = [['', 'As broadcast'], ['en', 'English'], ['es', 'Spanish'], ['fr', 'French'], ['ko', 'Korean'],28302923 ['vi', 'Vietnamese'], ['zh', 'Chinese'], ['ru', 'Russian'], ['pt', 'Portuguese']];
@@ -2832,7 +2925,7 @@ const GUIDE_HOURS = [6, 12, 24, 36, 48, 72];
28322925 const WATCHED = [[0, 'Keep them'], [1, 'Delete after a day'], [7, 'Delete after a week'], [30, 'Delete after a month']];28332926 const SETTINGS_LISTS = { hidden: 'Hidden channels', controls: 'Remote, controller and keys', sources: 'Data sources' };28342927 // How to hide a channel, with the keys there are.2835-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');2928+const HIDE_HOW = () => (onRemote() ? 'In the guide, hold OK on a channel\'s program: Hide channel' : 'In the guide, H hides a channel');28362929 28372930 // padNames names face buttons ("A, Y") as the pad in use labels them, by28382931 // their place in the standard mapping.
@@ -2888,19 +2981,79 @@ function wireSettings() {
28882981 if (e.target.name === 'server' || e.target.name === 'token') saveServer(e.target.name, e.target.value);28892982 });28902983 list.addEventListener('focusout', () => setTimeout(renderSettings));2891- f(v, 'index').addEventListener('click', (e) => {2892- const b = e.target.closest('[data-section]');2893- if (!b) return;2894- if (settingsUI.sub) closeSettingsList();2895- moveSetting(settingsUI.rows.findIndex((r) => r.section === b.dataset.section));2984+ f(v, 'tabs').addEventListener('click', (e) => {2985+ const b = e.target.closest('[data-tab]');2986+ if (b) pickSettingsTab(b.dataset.tab, 'list');28962987 });28972988 }28982989 2899-// openSettings shows the list from the top level, where it was left.2900-function openSettings() {2990+// settingsTabs are the tabs there are: Reception with an antenna, and2991+// those with rows.2992+function settingsTabs() {2993+ const sections = new Set(settingsRows().map((r) => r.section));2994+ return SETTINGS_TABS.filter(([k, label]) => (k === 'reception' ? !!state.info && state.info.antenna !== false : sections.has(label)));2995+}2996+2997+const receptionShown = () => state.view === 'settings' && settingsUI.tab === 'reception';2998+2999+// openSettings shows a tab, the one left open when none is asked for, with3000+// the focus on the tabs or in the tab (zone 'list').3001+function openSettings(tab = settingsUI.tab, zone = 'tabs') {29013002 settingsUI.sub = null;3003+ pickSettingsTab(tab, zone);3004+}3005+3006+function pickSettingsTab(tab, zone = settingsUI.zone) {3007+ const tabs = settingsTabs();3008+ if (!tabs.some(([k]) => k === tab)) tab = tabs.length ? tabs[0][0] : 'display';3009+ if (tab !== settingsUI.tab) {3010+ settingsUI.at = 0;3011+ settingsUI.sub = null;3012+ }3013+ settingsUI.tab = tab;3014+ const reception = tab === 'reception';3015+ $('#settings').dataset.tab = tab;3016+ f($('#settings'), 'list').hidden = reception;3017+ $('#antenna').hidden = !reception;29023018 renderSettings();2903- moveSetting(settingsUI.at);3019+ if (reception) {3020+ renderAntenna();3021+ signalAt = 0; // fresh measurements3022+ if (zone === 'list') antUI.zone = antRows().length ? 'table' : 'head';3023+ } else f($('#settings'), 'list').scrollTop = 0;3024+ settingsZone(zone);3025+}3026+3027+// settingsZone puts the focus on the tabs, or in the tab.3028+function settingsZone(zone) {3029+ settingsUI.zone = zone;3030+ $('#settings').dataset.zone = zone;3031+ if (state.dock >= 0 || state.view !== 'settings') return;3032+ if (zone === 'tabs') {3033+ blurIn($('#settings .s-main'));3034+ focusEl($(`#settings [data-tab="${settingsUI.tab}"]`));3035+ } else if (settingsUI.tab === 'reception') {3036+ blurIn($('#settings .rail'));3037+ antZone(antUI.zone);3038+ } else {3039+ blurIn($('#settings .rail'));3040+ moveSetting(settingsUI.at);3041+ }3042+ renderSettingsHint();3043+}3044+3045+function settingsRefocus() {3046+ if (state.view !== 'settings' || state.dock >= 0) return;3047+ settingsZone(settingsUI.zone);3048+}3049+3050+// stepSettingsTab moves to the tab before or after, staying in tabs or3051+// in the tab.3052+function stepSettingsTab(dir) {3053+ const tabs = settingsTabs();3054+ const i = tabs.findIndex(([k]) => k === settingsUI.tab);3055+ const next = tabs[Math.max(0, Math.min(tabs.length - 1, i + dir))];3056+ if (next && next[0] !== settingsUI.tab) pickSettingsTab(next[0]);29043057 }29053058 29063059 const settingsValue = (r) => (r.id in settingsUI.pending ? settingsUI.pending[r.id] : r.get());
@@ -2917,6 +3070,11 @@ function settingsRows() {
29173070 id: 'scale', kind: 'choice', label: 'Interface size', sub: 'Larger reads better across a room',29183071 choices: SCALES.map((x) => [x, x ? `${x}%` : `Auto, ${autoScale()}%`]), get: () => s.scale || 0, set: setScale,29193072 });3073+ const auto = PLATFORMS.find(([k]) => k === detectedPlatform)[1];3074+ add('Display', {3075+ id: 'platform', kind: 'choice', label: 'Layout', subFor: (k) => PLATFORM_SUBS[k || detectedPlatform],3076+ choices: [['', `Auto, ${auto}`], ...PLATFORMS], get: () => localStorage.getItem(platformKey) || '', set: setPlatform,3077+ });29203078 const detected = PAD_LABELS.find(([k]) => k === prompts.detected)[1];29213079 add('Display', {29223080 id: 'padlabels', kind: 'choice', label: 'Button labels', sub: 'How hints name a controller\'s buttons',
@@ -3009,21 +3167,13 @@ function renderSettings() {
30093167 // Not under the cursor while something is typed.30103168 if (list.contains(document.activeElement) && document.activeElement.matches('input')) return;30113169 if (state.view !== 'settings') settingsUI.sub = null;3012- const main = settingsRows();3013- settingsUI.sections = [...new Set(main.map((r) => r.section))];3014- const rows = settingsUI.rows = settingsUI.sub ? settingsList(settingsUI.sub) : main;3170+ renderSettingsTabs();3171+ const label = (SETTINGS_TABS.find(([k]) => k === settingsUI.tab) || [])[1];3172+ const rows = settingsUI.rows = settingsUI.sub ? settingsList(settingsUI.sub) : settingsRows().filter((r) => r.section === label);30153173 settingsUI.at = Math.min(settingsUI.at, Math.max(0, rows.length - 1));30163174 let html = '';3017- let section = '';3018- rows.forEach((r, i) => {3019- if (r.section !== section) {3020- html += `${section ? '</div>' : ''}<h2 class="s-head">${esc(r.section)}</h2><div class="s-group">`;3021- section = r.section;3022- }3023- html += settingsRowHTML(r, i);3024- });3025- if (section) html += '</div>';3026- if (settingsUI.sub === 'controls' && androidTV) {3175+ if (rows.length) html += `<h2 class="s-head">${esc(rows[0].section)}</h2><div class="s-group">${rows.map(settingsRowHTML).join('')}</div>`;3176+ if (settingsUI.sub === 'controls' && platform === 'tv') {30273177 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>';30283178 } else if (settingsUI.sub === 'controls') {30293179 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, Shift F favorite, R record, D recordings, A reception, 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>';
@@ -3031,7 +3181,6 @@ function renderSettings() {
30313181 const keep = list.scrollTop;30323182 list.innerHTML = html;30333183 list.scrollTop = keep;3034- renderSettingsIndex();30353184 renderSettingsHint();30363185 }30373186
@@ -3055,16 +3204,27 @@ function settingsRowHTML(r, i) {
30553204 <div class="s-label"><b>${esc(r.label)}</b>${sub ? `<small>${esc(sub)}</small>` : ''}</div><div class="s-value">${value}</div></div>`;30563205 }30573206 3058-// renderSettingsIndex lists the sections beside the rows, marking where the3059-// focus is. It follows the focus; the arrows never land on it.3060-function renderSettingsIndex() {3061- const r = settingsUI.sub ? null : settingsUI.rows[settingsUI.at];3062- const cur = r ? r.section : (settingsUI.from >= 0 && settingsRows()[settingsUI.from] || {}).section;3063- f($('#settings'), 'index').innerHTML = settingsUI.sections.map((s) => `<button type="button" class="rm-item ${s === cur ? 'on' : ''}" data-section="${esc(s)}" tabindex="-1">3064- <span class="rm-label">${esc(s)}</span></button>`).join('');3207+// renderSettingsTabs lists the tabs in the rail, the open one marked.3208+function renderSettingsTabs() {3209+ const box = f($('#settings'), 'tabs');3210+ const html = settingsTabs().map(([k, label]) => `<button type="button" class="rm-item fx ${k === settingsUI.tab ? 'on' : ''}" data-tab="${k}" role="tab" aria-selected="${k === settingsUI.tab}">3211+ <span class="rm-label">${esc(label)}</span></button>`).join('');3212+ if (box.innerHTML === html) return;3213+ const had = box.contains(document.activeElement);3214+ box.innerHTML = html;3215+ if (had) focusEl($(`[data-tab="${settingsUI.tab}"]`, box), false);30653216 }30663217 30673218 function renderSettingsHint() {3219+ const hint = f($('#settings'), 'hint');3220+ if (settingsUI.zone === 'tabs') {3221+ hint.innerHTML = hints([['UpDown', 'Up, Down', 'picks'], ['Enter', 'Enter', 'goes in'], ['Escape', 'Esc', 'leaves']]);3222+ return;3223+ }3224+ if (settingsUI.tab === 'reception') {3225+ hint.innerHTML = hints([['Arrows', 'Arrows', 'move'], ['Enter', 'Enter', 'measures, or watches the RF channel picked'], ['Escape', 'Esc', 'back to the tabs']]);3226+ return;3227+ }30683228 const r = settingsUI.rows[settingsUI.at];30693229 const top = settingsUI.at === 0 && !settingsUI.sub;30703230 const items = [['UpDown', 'Up, Down', top ? 'move, Up to the menu' : 'move']];
@@ -3073,8 +3233,8 @@ function renderSettingsHint() {
30733233 if (r && r.kind === 'open') items.push(['Enter', 'Enter', 'opens']);30743234 if (r && r.kind === 'action') items.push(['Enter', 'Enter', r.verb || 'runs']);30753235 if (r && r.kind === 'text') items.push(['Enter', 'Enter', androidTV ? 'types in it' : 'types in it, with a keyboard']);3076- items.push(['Escape', 'Esc', settingsUI.sub ? 'back' : 'leaves Settings']);3077- f($('#settings'), 'hint').innerHTML = hints(items);3236+ items.push(['Escape', 'Esc', settingsUI.sub ? 'back' : 'back to the tabs']);3237+ hint.innerHTML = hints(items);30783238 }30793239 30803240 function moveSetting(i, scroll = true) {
@@ -3089,13 +3249,19 @@ function moveSetting(i, scroll = true) {
30893249 if (scroll && settingsUI.at === 0) list.scrollTop = 0;30903250 else if (scroll) el.scrollIntoView({ block: 'nearest' });30913251 }3092- renderSettingsIndex();30933252 renderSettingsHint();30943253 }30953254 30963255 // settingsKey handles a key in Settings, or returns false to leave it to3097-// the keys every view has (Esc at the top level goes back to TV).3256+// the keys every view has (Esc goes back to TV).30983257 function settingsKey(e) {3258+ if (!settingsUI.sub && (e.key === 'PageUp' || e.key === 'PageDown' || e.key === '[' || e.key === ']')) {3259+ e.preventDefault();3260+ stepSettingsTab(e.key === 'PageDown' || e.key === ']' ? 1 : -1);3261+ return true;3262+ }3263+ if (settingsUI.zone === 'tabs') return settingsTabsKey(e);3264+ if (settingsUI.tab === 'reception') return antennaKey(e);30993265 const r = settingsUI.rows[settingsUI.at];31003266 switch (e.key) {31013267 case 'ArrowUp':
@@ -3103,12 +3269,12 @@ function settingsKey(e) {
31033269 else if (!settingsUI.sub) openDock();31043270 break;31053271 case 'ArrowDown': moveSetting(settingsUI.at + 1); break;3106- case 'PageUp': case 'PageDown': moveSetting(settingsPage(e.key === 'PageDown' ? 1 : -1)); break;3272+ case 'PageUp': case 'PageDown': moveSetting(settingsUI.at + (e.key === 'PageDown' ? 8 : -8)); break;31073273 case 'ArrowLeft':31083274 if (r && r.kind === 'choice') stepSetting(r, -1);31093275 else if (r && r.kind === 'switch') { if (settingsValue(r)) changeSetting(r, false); }31103276 else if (settingsUI.sub) closeSettingsList();3111- else openDock();3277+ else settingsZone('tabs');31123278 break;31133279 case 'ArrowRight':31143280 if (r && r.kind === 'choice') stepSetting(r, 1);
@@ -3129,24 +3295,20 @@ function settingsKey(e) {
31293295 return true;31303296 }31313297 3132-// settingsPage is where Page Up or Down goes: the next section, or the3133-// start of this one (the one before when already there); in a list, eight3134-// rows on.3135-function settingsPage(dir) {3136- const rows = settingsUI.rows;3137- const at = settingsUI.at;3138- if (settingsUI.sub) return at + dir * 8;3139- const cur = rows[at].section;3140- if (dir > 0) {3141- const i = rows.findIndex((r, j) => j > at && r.section !== cur);3142- return i < 0 ? rows.length - 1 : i;3298+// settingsTabsKey: Up and Down pick a tab (Up from the first to the dock),3299+// Right or OK goes into it, Left to the dock.3300+function settingsTabsKey(e) {3301+ const tabs = settingsTabs();3302+ const i = tabs.findIndex(([k]) => k === settingsUI.tab);3303+ switch (e.key) {3304+ case 'ArrowUp': if (i > 0) stepSettingsTab(-1); else openDock(); break;3305+ case 'ArrowDown': stepSettingsTab(1); break;3306+ case 'ArrowRight': case 'Enter': settingsZone('list'); break;3307+ case 'ArrowLeft': openDock(); break;3308+ default: return false;31433309 }3144- let i = at;3145- while (i > 0 && rows[i - 1].section === cur) i--;3146- if (i < at || i === 0) return i;3147- const prev = rows[i - 1].section;3148- while (i > 0 && rows[i - 1].section === prev) i--;3149- return i;3310+ e.preventDefault();3311+ return true;31503312 }31513313 31523314 function stepSetting(r, dir, wrap = false) {
@@ -3300,7 +3462,9 @@ async function saveServer(name, value) {
33003462 // On the Android TV app sizes are a 1080p screen's, whatever the WebView's33013463 // (fit, from web.js).33023464 const SCALES = [0, 100, 115, 130, 150]; // 0 is Auto3303-const autoScale = () => (state.boot && state.boot.autoScale) || 100;3465+// Auto is the app's for the platform it says it is; for another, chosen in3466+// Settings, a desktop's 100% or 125% across a room.3467+const autoScale = () => (platform === detectedPlatform ? (state.boot && state.boot.autoScale) || 100 : platform === 'desktop' ? 100 : 125);33043468 let pageZoom = 0;33053469 33063470 function applyScale() {
@@ -3330,6 +3494,21 @@ function applyScale() {
33303494 if (state.view === 'guide' && state.report) renderGuide();33313495 }33323496 3497+const PLATFORM_SUBS = {3498+ desktop: 'For a mouse and a keyboard',3499+ console: 'For a controller, across a room',3500+ tv: 'For a TV remote: arrows, OK and Back',3501+};3502+3503+async function setPlatform(k) {3504+ if (k) localStorage.setItem(platformKey, k); else localStorage.removeItem(platformKey);3505+ if (applyPlatform()) {3506+ applyScale();3507+ renderHints();3508+ }3509+ toast(`Layout: ${k ? PLATFORMS.find(([x]) => x === k)[1] : `automatic, ${PLATFORMS.find(([x]) => x === detectedPlatform)[1]}`}`, 1800);3510+}3511+33333512 function setPadLabels(k) {33343513 if (k) localStorage.setItem(padLabelsKey, k); else localStorage.removeItem(padLabelsKey);33353514 applyPadFamily();
@@ -3369,7 +3548,7 @@ function renderRecordingBanner(b, it) {
33693548 const pos = (state.recOffset || 0) + (video.currentTime || 0);33703549 f(b, 'progress').style.width = it.duration ? `${Math.min(100, (pos / it.duration) * 100)}%` : '0';33713550 f(b, 'desc').textContent = it.description || '';3372- f(b, 'next').innerHTML = hints([['LeftRight', 'Arrows', 'skip'], ...(androidTV || prompts.pad ? [] : [[' ', 'Space', 'pauses']]), ['Enter', 'Enter', 'controls'], ['Escape', 'Esc', 'returns to live TV']]);3551+ f(b, 'next').innerHTML = hints([['LeftRight', 'Arrows', 'skip'], ...(onKeys() ? [[' ', 'Space', 'pauses']] : []), ['Enter', 'Enter', 'controls'], ['Escape', 'Esc', 'returns to live TV']]);33733552 f(b, 'meter').innerHTML = '';33743553 f(b, 'tier').textContent = sizeLabel(it.sizeBytes);33753554 f(b, 'tier').style.color = '';
@@ -3474,7 +3653,7 @@ function renderRecordings() {
34743653 const tab = state.recTab || 'library';34753654 const sel = state.recSelBy || (state.recSelBy = { library: 0, upcoming: 0, series: 0 });34763655 for (const k of Object.keys(sel)) sel[k] = Math.min(sel[k], Math.max(0, lists[k].length - 1));3477- 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>`;3656+ const act = (name, label, it, cls = '') => `<button type="button" class="act fx${cls ? ` ${cls}` : ''}${armed(name, it) ? ' armed' : ''}" data-act="${name}">${armed(name, it) ? `${onRemote() ? 'OK' : 'Press'} again to ${ARMED[name]}` : label}</button>`;34783657 34793658 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('');34803659 $$('.r-panel', v).forEach((p) => p.classList.toggle('on', p.dataset.panel === tab));
@@ -3519,12 +3698,12 @@ function renderRecordings() {
35193698 <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>35203699 <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>35213700 </li>`).join('') : '<li class="r-empty">No series recordings. In the guide, OK on a show to come, then Record series.</li>';3522- 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>');3701+ if (lists.series.length && onKeys()) f(v, 'rules').insertAdjacentHTML('beforeend', '<li class="r-hint">K changes how many to keep | N new episodes only | Delete stops the series</li>');35233702 recRefocus();35243703 }35253704 35263705 function recHint(cur) {3527- if (androidTV && !prompts.pad) return 'OK for the actions, Left for the other lists';3706+ if (onRemote()) return 'OK for the actions, Left for the other lists';35283707 return hints([['Enter', 'Enter', 'for the actions'], ...(resumable(cur) ? [['', 'Shift Enter', 'plays from the start']] : []), ['w', 'W', cur.watched ? 'unwatched' : 'watched'], ['', 'Delete', 'removes']]);35293708 }35303709
@@ -3704,6 +3883,7 @@ const resumable = (it) => !!it && !it.watched && it.position > 30 && it.duration
37043883 async function playRecording(it, from = 0) {37053884 if (!it || it.status === 'failed') return toast('That recording failed and cannot be played');37063885 const token = ++state.tuneToken;3886+ state.staleStream = false;37073887 state.recording = it;37083888 showWX(false);37093889 setView('tv');
@@ -3746,7 +3926,7 @@ async function hideChannel(ch) {
37463926 state.lastHidden = ch.key;37473927 await saveAppSettings({ ...state.settings, hidden: [...hidden] });37483928 buildLineup();3749- toast(`Hid ${ch.number} ${ch.network || displayCall(ch)}. ${androidTV && !prompts.pad ? 'Settings, Hidden channels shows it again.' : 'Press U to undo.'}`, 5000);3929+ toast(`Hid ${ch.number} ${ch.network || displayCall(ch)}. ${onKeys() ? 'Press U to undo.' : 'Settings, Hidden channels shows it again.'}`, 5000);37503930 if (state.view === 'guide') renderGuide();37513931 }37523932
@@ -3792,10 +3972,42 @@ function skip(seconds) {
37923972 }37933973 37943974 function togglePause() {3975+ if (video.paused && state.staleStream) return restartStream();37953976 if (video.paused) video.play().catch(() => {}); else video.pause();37963977 showTimeshift();37973978 }37983979 3980+// ---------- heartbeat ----------3981+// While a stream is open, playing or paused, the app says so every 30 s:3982+// the server stops streams nobody fetches from for 2 minutes, freeing3983+// their tuners. One it stopped anyway (the app was asleep, or in the3984+// background) starts again at once if it was playing on TV, else on the3985+// next Play or return to TV.3986+async function heartbeat() {3987+ if (!(video.src || state.hls) || state.staleStream) return;3988+ let alive = true;3989+ try {3990+ alive = await api().Heartbeat();3991+ } catch (e) {3992+ return; // the server is away; the player has its own errors3993+ }3994+ if (alive) return;3995+ log('info', 'the server had stopped the stream');3996+ if (state.view === 'tv' && !video.paused) restartStream();3997+ else state.staleStream = true;3998+}3999+4000+// restartStream starts the stream on again: the channel, or the recording4001+// from where it is.4002+function restartStream() {4003+ state.staleStream = false;4004+ if (state.recording) {4005+ const it = state.recording;4006+ return playRecording(it, (state.recOffset || 0) + (video.currentTime || 0));4007+ }4008+ return state.current ? tune(state.current, { quiet: true }) : undefined;4009+}4010+37994011 function goLive() {38004012 if (state.recording) return;38014013 video.currentTime = livePoint();
M frontend/dist/index.html +37 -38
@@ -91,40 +91,6 @@
9191 </div>9292 </section>9393 94- <!-- Reception: what the tuners measured, by RF channel (app.js) -->95- <section id="antenna" class="view railed" aria-label="Reception">96- <nav class="rail">97- <div class="a-tuner fx" data-f="tuner" data-act="tuner" tabindex="-1"></div>98- <div class="a-measure">99- <button type="button" class="btn fx" data-act="measure" data-f="measure"><span data-f="mlabel">Measure now</span></button>100- <div class="a-mprog" data-f="mprog" hidden><i data-f="mbar"></i></div>101- <div class="a-mnote" data-f="mnote"></div>102- </div>103- <div class="a-measure">104- <button type="button" class="btn fx" data-act="scan" data-f="scan">Scan for channels</button>105- <div class="a-mnote" data-f="snote"></div>106- </div>107- <dl class="rail-stats" data-f="stats"></dl>108- </nav>109- <div class="main a-body">110- <div class="a-left">111- <div class="radar-box"><svg class="radar" viewBox="-226 -226 452 452" role="img" aria-label="Transmitter map"></svg></div>112- <div class="a-detail" data-f="detail"></div>113- </div>114- <div class="a-right">115- <div class="a-tablewrap">116- <table class="a-table">117- <thead><tr>118- <th>RF</th><th>Transmitter</th><th>Channels</th><th class="c">Signal</th><th class="r">Quality</th><th class="r">Errors</th><th class="r">Measured</th>119- </tr></thead>120- <tbody></tbody>121- </table>122- </div>123- <div class="a-nextgen" data-f="nextgen"></div>124- </div>125- </div>126- </section>127-12894 <!-- Weather -->12995 <section id="weather" class="view railed" aria-label="Weather">13096 <nav class="rail">
@@ -160,11 +126,45 @@
160126 </div>161127 </section>162128 163- <!-- Settings: one list of rows for a remote or a controller (app.js) -->129+ <!-- More: Settings, a tab for each area, in the rail; most are a list of130+ rows for a remote or a controller, Reception is what the tuners131+ measured (app.js, settings and reception). -->164132 <section id="settings" class="view railed" aria-label="Settings">165- <nav class="rail"><div class="rail-menu s-index" data-f="index" aria-hidden="true"></div></nav>133+ <nav class="rail"><div class="rail-menu s-tabs" data-f="tabs" role="tablist"></div></nav>166134 <div class="main s-main">167135 <div class="s-list" data-f="list" role="list"></div>136+ <div id="antenna" class="s-pane" aria-label="Reception" hidden>137+ <div class="a-head">138+ <div class="a-tuner fx" data-f="tuner" data-act="tuner" tabindex="-1"></div>139+ <div class="a-measure">140+ <button type="button" class="btn fx" data-act="measure" data-f="measure"><span data-f="mlabel">Measure now</span></button>141+ <div class="a-mprog" data-f="mprog" hidden><i data-f="mbar"></i></div>142+ <div class="a-mnote" data-f="mnote"></div>143+ </div>144+ <div class="a-measure">145+ <button type="button" class="btn fx" data-act="scan" data-f="scan">Scan for channels</button>146+ <div class="a-mnote" data-f="snote"></div>147+ </div>148+ <dl class="rail-stats a-stats" data-f="stats"></dl>149+ </div>150+ <div class="a-body">151+ <div class="a-left">152+ <div class="radar-box"><svg class="radar" viewBox="-226 -226 452 452" role="img" aria-label="Transmitter map"></svg></div>153+ <div class="a-detail" data-f="detail"></div>154+ </div>155+ <div class="a-right">156+ <div class="a-tablewrap">157+ <table class="a-table">158+ <thead><tr>159+ <th>RF</th><th>Transmitter</th><th>Channels</th><th class="c">Signal</th><th class="r">Quality</th><th class="r">Errors</th><th class="r">Measured</th>160+ </tr></thead>161+ <tbody></tbody>162+ </table>163+ </div>164+ <div class="a-nextgen" data-f="nextgen"></div>165+ </div>166+ </div>167+ </div>168168 <div class="s-hint" data-f="hint"></div>169169 </div>170170 </section>
@@ -174,8 +174,7 @@
174174 <button data-view="guide"><kbd data-key="g" data-label="G">G</kbd>Guide</button>175175 <button data-view="recordings"><kbd data-key="d" data-label="D">D</kbd>Recordings</button>176176 <button data-view="weather"><kbd data-key="w" data-label="W">W</kbd>Weather</button>177- <button data-view="antenna"><kbd data-key="a" data-label="A">A</kbd>Reception</button>178- <button data-view="settings"><kbd data-key="," data-label=",">,</kbd>Settings</button>177+ <button data-view="settings"><kbd data-key="," data-label=",">,</kbd>More</button>179178 </nav>180179 181180 <div id="boot">
M frontend/dist/style.css +24 -14
@@ -89,7 +89,7 @@ body.mode-guide #stage {
8989 }9090 /* Every other view keeps the TV in the same top-left corner as the guide. */9191 :root { --pip-w: 224px; --pip-h: 126px; --pip-gap: 252px; }92-body.mode-antenna #stage, body.mode-recordings #stage, body.mode-settings #stage, body.mode-weather #stage {92+body.mode-recordings #stage, body.mode-settings #stage, body.mode-weather #stage {9393 z-index: 25; top: 46px; left: 28px; width: var(--pip-w); height: var(--pip-h);9494 border-radius: 4px; box-shadow: 0 0 0 1px var(--line-2), 0 18px 40px rgba(0,0,0,.7); cursor: pointer;9595 }
@@ -186,7 +186,7 @@ body.mode-tv #banner.show { transform: none; opacity: 1; }
186186 background: radial-gradient(120% 80% at 70% 0%, #11141a 0%, var(--ink) 60%);187187 transition: opacity .35s, visibility 0s .35s;188188 }189-body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings, body.mode-recordings #recordings, body.mode-weather #weather {189+body.mode-guide #guide, body.mode-settings #settings, body.mode-recordings #recordings, body.mode-weather #weather {190190 opacity: 1; pointer-events: auto; visibility: visible; transition: opacity .35s;191191 }192192
@@ -274,8 +274,8 @@ body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings
274274 .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); }275275 .g-now span { position: absolute; top: 0; left: -5px; border: 6px solid transparent; border-top: 8px solid var(--amber); }276276 277-/* ---------- Reception ---------- */278-/* Rail layout shared by reception, recordings and settings: the TV preview277+/* ---------- Rail views ---------- */278+/* Rail layout shared by recordings, settings and weather: the TV preview279279 sits at the top of a left rail, with that view's menu beneath it. */280280 .view.railed { display: grid; grid-template-columns: var(--pip-w) minmax(0, 1fr); gap: 34px; padding: 46px 28px 22px; }281281 .rail { padding-top: calc(var(--pip-h) + 24px); display: flex; flex-direction: column; gap: 30px; min-height: 0; }
@@ -296,7 +296,15 @@ body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings
296296 .rail-stats dd { margin: 4px 0 0; font: 800 36px/1 var(--f-display); color: var(--paper); }297297 .rail-stats dd small { display: block; margin-top: 5px; font: 400 12px/1.35 var(--f-body); color: var(--muted); }298298 .main { min-height: 0; }299-/* The tuner, and Measure now with its progress, in the rail. */299+/* ---------- Reception (a tab of Settings) ---------- */300+/* Above the map and the table: the tuner, Measure now with its progress,301+ Scan, and how many channels there are. */302+#antenna { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 22px; min-height: 0; }303+#antenna[hidden] { display: none; }304+.a-head { display: grid; grid-template-columns: minmax(220px, 1.2fr) minmax(190px, 1fr) minmax(190px, 1fr) auto; gap: 18px; align-items: start; }305+.a-head .a-measure { margin-top: 0; }306+.a-stats { grid-auto-flow: column; gap: 26px; padding: 4px 6px 0; }307+.a-stats dd { font-size: 30px; }300308 .a-tuner { display: grid; gap: 3px; padding: 12px 14px; border-radius: 3px; background: var(--ink-2); box-shadow: inset 3px 0 0 var(--line-2); }301309 .at-label { font: 500 10px var(--f-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--faint); }302310 .at-name { font: 800 30px/1 var(--f-display); color: var(--paper); margin: 3px 0 2px; }
@@ -392,6 +400,7 @@ body.mode-guide #guide, body.mode-antenna #antenna, body.mode-settings #settings
392400 a controller: the focused row is filled and outlined in amber. */393401 #settings { grid-template-rows: minmax(0, 1fr); }394402 .s-main { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; max-width: 940px; }403+#settings[data-tab="reception"] .s-main { max-width: none; }395404 .s-list { overflow: auto; min-height: 0; padding: 4px 2px 48px; scrollbar-width: none; }396405 .s-list::-webkit-scrollbar { display: none; }397406 .s-head { margin: 28px 0 10px 18px; font: 500 11px var(--f-mono); letter-spacing: .16em; text-transform: uppercase; color: var(--amber); }
@@ -430,10 +439,9 @@ body.mouse-active .s-row:hover { background: var(--ink-3); } /* not under a rest
430439 .s-row.k-info { cursor: default; }431440 .s-note { margin: 16px 20px 0; max-width: 80ch; font-size: 13px; color: var(--muted); }432441 .s-hint { padding: 12px 20px 0; border-top: 1px solid var(--line); font: 400 12px var(--f-mono); letter-spacing: .04em; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }433-/* The sections beside the list: where the focus is, not a second menu. */434-.s-index .rm-item { padding: 6px 14px; color: var(--faint); }435-.s-index .rm-item .rm-label { font-size: 19px; }436-.s-index .rm-item.on { background: none; color: var(--amber); }442+/* The tabs, in the rail. */443+.s-tabs .rm-item { padding: 9px 14px; }444+.s-tabs .rm-item .rm-label { font-size: 21px; }437445 .btn { border: 0; cursor: pointer; padding: 12px 18px; border-radius: 3px; background: var(--amber); color: var(--ink); font: 500 11px var(--f-mono); letter-spacing: .14em; text-transform: uppercase; justify-self: start; }438446 .btn.ghost { background: none; color: var(--paper); box-shadow: inset 0 0 0 1px var(--line-2); }439447 .btn:hover { filter: brightness(1.1); }
@@ -460,8 +468,10 @@ body.pad-input #dock kbd { padding: 0; box-shadow: none; }
460468 body.dock-nav #dock { opacity: 1; transform: translate(-50%, 0); }461469 #dock button.pick kbd { color: var(--amber); }462470 body.osd-open #dock { opacity: 1; transform: translate(-50%, 0); }463-/* Larger on a TV, where it's the way to every view. */464-body.android-tv #dock button { padding: 7px 15px 7px 11px; font-size: 13px; }471+/* Recordings that failed, until Recordings is opened. */472+#dock .badge { min-width: 17px; padding: 1px 5px; border-radius: 999px; background: var(--red); color: var(--paper); font: 600 10px/15px var(--f-mono); letter-spacing: 0; text-align: center; }473+/* Larger across a room, where it's the way to every view. */474+body.pf-tv #dock button, body.pf-console #dock button { padding: 7px 15px 7px 11px; font-size: 13px; }465475 466476 #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; }467477 body:not(.booting) #boot { opacity: 0; visibility: hidden; }
@@ -753,9 +763,9 @@ body.lite { --glow: 0 0 0 0 transparent; }
753763 .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); }754764 .wx-main .fx:focus, .a-tuner.fx:focus { box-shadow: var(--ring-in), var(--glow); }755765 body:not(.dock-nav) #guide[data-zone="grid"] .g-cell.sel,756-body:not(.dock-nav) .s-row.focus,766+body:not(.dock-nav) #settings[data-zone="list"] .s-row.focus,757767 body:not(.dock-nav) #recordings[data-zone="list"] .r-item.sel,758-body:not(.dock-nav) #antenna[data-zone="table"] .a-table tr.sel {768+body:not(.dock-nav) #settings[data-zone="list"] #antenna[data-zone="table"] .a-table tr.sel {759769 box-shadow: var(--ring-in), var(--glow); background: var(--focus-fill); transform: var(--lift);760770 }761771 body:not(.dock-nav) #recordings[data-zone="list"] .r-card.sel { box-shadow: var(--ring), var(--glow); transform: var(--lift); }
@@ -778,4 +788,4 @@ body:not(.dock-nav) #recordings[data-zone="list"] .r-card.sel { box-shadow: var(
778788 .act.fx.armed:focus { box-shadow: var(--ring), var(--glow); color: var(--red); }779789 /* A field being typed in has the ring, not its row. */780790 .boot-connect input:focus, .s-row input:focus { box-shadow: var(--ring); border-color: var(--amber); }781-body:not(.dock-nav) .s-row.focus:focus-within { box-shadow: inset 0 0 0 1px var(--line-2); transform: none; }791+body:not(.dock-nav) #settings[data-zone="list"] .s-row.focus:focus-within { box-shadow: inset 0 0 0 1px var(--line-2); transform: none; }
M frontend/dist/web.js +7 -2