The web app has a manifest, icons rendered from the launcher's art, and a service worker that keeps nothing offline but says when the server can't be reached, so a phone or a browser can install it from a secure address such as a Tailscale Service. Touch is its own platform (body.pf-touch, js/touch.js), found by a touch screen without a mouse: a tap on TV brings up the controls, swipes skip and change channel, the guide swipes through the hours, a second tap watches or plays, and holding a program shows its actions. Nothing names keys. A phone on its side gets a compact banner and guide, and the picture-in-picture goes; held upright it asks to be turned. The tests check that no name is declared in two of the app's files, which share one scope.
- commit
- 114b5948464065e2b8c9804bfd3d2dd85de51dba
- parent
- b7fa79df4c0b287deb4035649bf617a915722eb4
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
- kind
- feat risk score 1.9 of 3, Jev's estimate
| M | README.md | 18 | +++++- |
| M | android/ | 9 | ++- |
| A | frontend/ | bin | |
| A | frontend/ | bin | |
| A | frontend/ | bin | |
| M | frontend/ | 14 | ++++- |
| M | frontend/ | 12 | +++- |
| M | frontend/ | 3 | + |
| M | frontend/ | 1 | + |
| M | frontend/ | 5 | ++ |
| M | frontend/ | 2 | + |
| M | frontend/ | 8 | ++- |
| M | frontend/ | 3 | +- |
| A | frontend/ | 91 | ++++++++++++++++++++++++++++++ |
| A | frontend/ | 19 | ++++++ |
| M | frontend/ | 62 | ++++++++++++++++++++- |
| A | frontend/ | 22 | +++++++ |
| M | frontend/ | 37 | +++++++++--- |
| M | frontend/ | 24 | ++++++++ |
| M | internal/ | 9 | ++- |
@@ -179,9 +179,9 @@ reached from the views above it: Up from a view's top, or Left from its left
179179 edge, and on TV, Up through the on-screen controls. Whatever the arrows are on has180180 an amber ring.181181 182-### Desktop, console and TV182+### Desktop, console, TV and touch183183 184-The app is three experiences, kept apart in the code (`platform` in js/core.js, `pf-*`184+The app is four experiences, kept apart in the code (`platform` in js/core.js, `pf-*`185185 classes in style.css):186186 187187 | | Where | In hand | Hints |@@ -189,12 +189,26 @@ classes in style.css):
189189 | Desktop | The app in a window on a Mac, Linux or Windows, or a browser | Mouse and keyboard; letters are shortcuts | Keys |190190 | Console | The Linux app under gamescope (SteamOS Game Mode) | A controller | Its buttons, from the start |191191 | TV | The Android TV app | A remote: arrows, OK, Back and media keys | The remote's keys; hold OK for more |192+| Touch | A phone or a tablet, at `/tv/` or installed from it | Taps, swipes and holds | None |192193 193194 The app says which it is (`AIRWAVES_PLATFORM=desktop`, `console` or `tv` overrides the194195 desktop app's guess; `?platform=` does the same at `/tv/`), and Settings, Display, Layout195196 can change it on one device. Console and TV start at 125%, sized for across a room. A196197 controller or a keyboard used on any of them still brings its own hints.197198 199+On touch, a tap on TV brings up the controls (and puts them away); a swipe left or right200+skips back 10 seconds or ahead 30, up or down changes channel. In the guide a swipe moves201+the hours shown, a tap picks a program and a second tap watches it, and holding one shows202+its actions; in Recordings and Podcasts a second tap plays. A phone is used on its side,203+with a compact banner and guide; held upright it asks to be turned.204+205+The web app is installable (`manifest.webmanifest`, and `sw.js`, which keeps nothing206+offline): from a secure address, such as a [Tailscale Service](https://tailscale.com/kb/1552/tailscale-services)207+in front of `airwavesd` (`tailscale serve --service=svc:airwaves --https=443208+http://<server>:8089`), Add to Home Screen on a phone or Install in a desktop browser209+opens it full screen with its own icon. A plain `http://` address works in a browser but210+can't be installed.211+198212 ## Data sources199213 200214 | Source | Used for | Cache |@@ -1,5 +1,6 @@
11 // Renders the Android TV app's banner, launcher icon and setup wordmark from2-// the HTML sources next to this file, into android/app/src/main/res. They share2+// the HTML sources next to this file, into android/app/src/main/res, and the3+// web app's icons into frontend/dist/icons. They share34 // the Steam art's look (build/linux/steam-art: art.css, art.js, the app's fonts).45 //56 // node android/art/render.mjs [banner|mark|icon ...] (needs the playwright package resolvable)@@ -27,6 +28,10 @@ const ART = {
2728 mark: ["mark.html", null, null, true, "drawable-nodpi/mark.png"],2829 ...Object.fromEntries(Object.entries(ICON).map(([d, px]) =>2930 [`icon-${d}`, [`icon.html?px=${px}`, px, px, true, `mipmap-${d}/ic_launcher_foreground.png`]])),31+ // The web app's: the W on its white tile, edge to edge, which a phone32+ // masks or rounds to its own shape (outputs from the repo's root).33+ ...Object.fromEntries([[192, "icon-192"], [512, "icon-512"], [180, "apple-touch-icon"]].map(([px, n]) =>34+ [`web-${n}`, [`icon.html?px=${px}`, px, px, false, `/frontend/dist/icons/${n}.png`]])),3035 };3136 3237 const TYPES = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".mjs": "text/javascript", ".woff2": "font/woff2", ".png": "image/png" };@@ -54,7 +59,7 @@ try {
5459 tab.on("requestfailed", (r) => console.error(`${name}: failed ${r.url()}`));5560 await tab.goto(base + page);5661 await tab.waitForSelector("body[data-ready]", { timeout: 15000 });57- const file = join(res, out);62+ const file = out.startsWith("/") ? join(root, out) : join(res, out);5863 await mkdir(dirname(file), { recursive: true });5964 await tab.locator("#art").screenshot({ path: file, omitBackground: transparent });6065 const box = await tab.locator("#art").boundingBox();Binary file.
Binary file.
Binary file.
@@ -2,8 +2,16 @@
22 <html lang="en">33 <head>44 <meta charset="utf-8">5- <meta name="viewport" content="width=device-width, initial-scale=1">5+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">66 <title>Airwaves</title>7+ <!-- Installable from a browser (manifest.webmanifest, sw.js in web.js). -->8+ <link rel="manifest" href="manifest.webmanifest">9+ <meta name="theme-color" content="#07080a">10+ <link rel="apple-touch-icon" href="icons/apple-touch-icon.png">11+ <meta name="apple-mobile-web-app-capable" content="yes">12+ <meta name="mobile-web-app-capable" content="yes">13+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">14+ <meta name="apple-mobile-web-app-title" content="Airwaves">715 <link rel="stylesheet" href="style.css">816 </head>917 <body class="mode-tv booting">@@ -208,6 +216,9 @@
208216 209217 <div id="toast" role="status"></div>210218 219+ <!-- A phone held upright, on the touch platform (style.css, Touch). -->220+ <div id="rotate" aria-live="polite"><div><div class="rotate-mark" aria-hidden="true"></div>Turn your phone sideways</div></div>221+211222 <script src="vendor/hls.min.js"></script>212223 <!-- The web app's stand-in for Wails, at /tv/ on airwavesd (web.js) -->213224 <script src="web.js"></script>@@ -216,6 +227,7 @@
216227 <script src="js/core.js"></script>217228 <script src="js/player.js"></script>218229 <script src="js/input.js"></script>230+ <script src="js/touch.js"></script>219231 <script src="js/guide.js"></script>220232 <script src="js/reception.js"></script>221233 <script src="js/settings.js"></script>@@ -141,19 +141,22 @@ const androidTV = /\bAirwavesTV\//.test(navigator.userAgent);
141141 // tv: the Android TV app. A remote with arrows, OK, Back and media keys142142 // but no letters or pointer: hints name the remote's keys, holding OK143143 // offers more, and the on-screen controls lead to everything.144+// touch: a phone or a tablet, the web app installed or in a browser.145+// Taps, swipes and holds (js/touch.js), no hints, sized to fit, and146+// held sideways.144147 // The app says which it is (Boot's platform: AIRWAVES_PLATFORM, else a145-// console's under gamescope; web.js: the Android TV app's TV, else a146-// desktop's, or ?platform=), and Settings, Display, Layout can say148+// console's under gamescope; web.js: the Android TV app's TV, a touch149+// screen's touch, else a desktop's, or ?platform=), and Settings, Display, Layout can say147150 // otherwise on this device. What's pressed still counts: a controller on a148151 // desktop brings its buttons to the hints, a keyboard on a TV its keys.149-const PLATFORMS = [['desktop', 'Desktop'], ['console', 'Console'], ['tv', 'TV']];152+const PLATFORMS = [['desktop', 'Desktop'], ['console', 'Console'], ['tv', 'TV'], ['touch', 'Touch']];150153 const platformKey = 'airwaves.platform';151154 let platform = 'desktop';152155 let detectedPlatform = 'desktop';153156 // onRemote: the hints and the ways to do things are a TV remote's.154157 const onRemote = () => platform === 'tv' && !prompts.pad;155158 // onKeys: they're a keyboard's, letters and all.156-const onKeys = () => platform !== 'tv' && !prompts.pad;159+const onKeys = () => (platform === 'desktop' || platform === 'console') && !prompts.pad;157160 158161 function applyPlatform(detected) {159162 if (detected) detectedPlatform = PLATFORMS.some(([k]) => k === detected) ? detected : 'desktop';@@ -416,6 +419,7 @@ async function init() {
416419 wirePodcasts();417420 wireWeather();418421 wirePads();422+ wireTouch();419423 wireMediaSession();420424 renderHints();421425 // Keys work from launch, without a click first.@@ -116,10 +116,13 @@ function wireGuide() {
116116 rows.addEventListener('click', (e) => {117117 const cell = e.target.closest('[data-row]');118118 if (!cell) return;119+ const picked = () => `${state.gRow}|${(selectedProgram(guideChannels()) || {})._s}`;120+ const was = guideUI.zone === 'grid' ? picked() : '';119121 guideZone('grid');120122 state.gRow = Number(cell.dataset.row);121123 if (cell.dataset.t) state.gTime = Number(cell.dataset.t);122124 renderGuide();125+ if (tappedAgain(was, picked())) guideActivate();123126 });124127 rows.addEventListener('dblclick', (e) => {125128 const cell = e.target.closest('[data-row]');@@ -558,6 +558,7 @@ const REMOTE_KEYS = { Enter: 'OK', Escape: 'Back', ' ': 'Play/Pause', Arrows: 'A
558558 const keyHint = (key, label) => {559559 if (prompts.pad) return glyph(key, label) || null;560560 if (platform === 'tv') return REMOTE_KEYS[key] ? esc(REMOTE_KEYS[key]) : null;561+ if (platform === 'touch') return null; // nothing to press561562 return esc(label);562563 };563564 @@ -128,6 +128,11 @@ function attempt(how, src) {
128128 if (err && err.name === 'NotAllowedError') {129129 video.muted = true;130130 video.play().catch(() => {});131+ // A touch screen's next touch, which lets sound play, turns it on.132+ if (platform === 'touch') {133+ document.addEventListener('pointerdown', () => { if (!state.userMuted) video.muted = false; }, { once: true, capture: true });134+ return toast('Started muted. Tap for sound.', 3500);135+ }131136 const m = keyHint('m', 'M');132137 if (m) return toast(`Started muted. Press ${m} for sound.`, 3500, true);133138 // A remote has no M: its next key, which lets sound play, turns it on.@@ -153,8 +153,10 @@ function wirePodcasts() {
153153 if (b) return podAct(b.dataset.act);154154 const card = e.target.closest('[data-i]');155155 if (card) {156+ const was = podUI.sel[podUI.list] || 0;156157 podUI.sel[podUI.list] = Number(card.dataset.i);157158 renderPodcasts();159+ if (tappedAgain(was, podUI.sel[podUI.list])) podAct('play');158160 }159161 return null;160162 });@@ -190,8 +190,14 @@ function wireRecordings() {
190190 if (b) return recAct(b.dataset.act);191191 const item = e.target.closest('[data-i]');192192 if (item && state.recSelBy) {193- state.recSelBy[state.recTab || 'library'] = Number(item.dataset.i);193+ const tab = state.recTab || 'library';194+ const was = state.recSelBy[tab];195+ state.recSelBy[tab] = Number(item.dataset.i);194196 renderRecordings();197+ if (tab === 'library' && tappedAgain(was, state.recSelBy[tab])) {198+ const it = recLists().library[was];199+ if (it) playOnDemand(it, resumable(it) ? it.position : 0);200+ }195201 }196202 return null;197203 });@@ -570,7 +570,7 @@ async function saveServer(name, value) {
570570 const SCALES = [0, 100, 115, 130, 150]; // 0 is Auto571571 // Auto is the app's for the platform it says it is; for another, chosen in572572 // Settings, a desktop's 100% or 125% across a room.573-const autoScale = () => (platform === detectedPlatform ? (state.boot && state.boot.autoScale) || 100 : platform === 'desktop' ? 100 : 125);573+const autoScale = () => (platform === detectedPlatform ? (state.boot && state.boot.autoScale) || 100 : platform === 'desktop' || platform === 'touch' ? 100 : 125);574574 let pageZoom = 0;575575 576576 function applyScale() {@@ -604,6 +604,7 @@ const PLATFORM_SUBS = {
604604 desktop: 'For a mouse and a keyboard',605605 console: 'For a controller, across a room',606606 tv: 'For a TV remote: arrows, OK and Back',607+ touch: 'For a phone or a tablet: tap, swipe and hold',607608 };608609 609610 async function setPlatform(k) {@@ -0,0 +1,91 @@
1+;2+3+// Touch: a phone or a tablet, the web app installed or in a browser.4+5+// ---------- touch ----------6+// Taps are clicks, which every view takes already; this adds what a finger7+// needs besides. On TV a tap brings up the controls and puts them away, a8+// swipe left or right skips as Left and Right do (back 10 s, ahead 30 s),9+// up or down changes channel as Up and Down do. In the guide a swipe left10+// or right moves the hours shown, a tap picks a program, a tap on the one11+// picked watches it or shows what's to come's actions, and holding one12+// shows its actions (record, favorite, hide). In Recordings and Podcasts a13+// tap on what's picked plays it. A phone held upright is asked to turn14+// sideways (style.css).15+const touch = { x: 0, y: 0, on: false, held: 0, longDone: false };16+const TOUCH_SWIPE = 48; // CSS pixels a finger travels for a swipe17+const TOUCH_HOLD_MS = 550; // a press this long is a hold18+19+const touching = (e) => platform === 'touch' && e.pointerType !== 'mouse';20+21+function wireTouch() {22+ document.addEventListener('pointerdown', (e) => {23+ if (!touching(e)) return;24+ Object.assign(touch, { x: e.clientX, y: e.clientY, on: true, longDone: false });25+ clearTimeout(touch.held);26+ const cell = e.target.closest && e.target.closest('#guide .g-rows [data-row]');27+ if (cell) touch.held = setTimeout(() => { touch.longDone = true; holdCell(cell); }, TOUCH_HOLD_MS);28+ }, { capture: true, passive: true });29+ document.addEventListener('pointermove', (e) => {30+ if (touch.on && Math.hypot(e.clientX - touch.x, e.clientY - touch.y) > 12) clearTimeout(touch.held);31+ }, { capture: true, passive: true });32+ document.addEventListener('pointercancel', () => { touch.on = false; clearTimeout(touch.held); }, true);33+ document.addEventListener('pointerup', (e) => {34+ if (!touch.on || !touching(e)) return;35+ touch.on = false;36+ clearTimeout(touch.held);37+ const dx = e.clientX - touch.x;38+ const dy = e.clientY - touch.y;39+ if (Math.max(Math.abs(dx), Math.abs(dy)) < TOUCH_SWIPE) return;40+ swipe(e.target, Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up'));41+ }, { capture: true, passive: true });42+ // A hold's lift isn't also a tap.43+ document.addEventListener('click', (e) => {44+ if (touch.longDone) {45+ touch.longDone = false;46+ e.stopPropagation();47+ e.preventDefault();48+ }49+ }, true);50+ // On TV a tap that isn't on a control, on the picture or the banner over51+ // it, brings up the controls or puts them away: seen first (capture), so52+ // a tap on the picture elsewhere, which goes back to TV (wireDock), isn't53+ // one.54+ document.addEventListener('click', (e) => {55+ if (platform !== 'touch' || state.view !== 'tv' || document.body.classList.contains('booting')) return;56+ if (e.target.closest('button, input, a, .ob, .tl, #dock, [data-act]')) return;57+ if (osd.open) closeOSD(); else openOSD(null);58+ }, true);59+}60+61+// swipe acts on a swipe over el: on TV, the arrows; in the guide, the62+// hours shown.63+function swipe(el, dir) {64+ if (state.view === 'tv' && (stage.contains(el) || el === document.body)) {65+ if (dir === 'left' || dir === 'right') {66+ skip(dir === 'right' ? 30 : -10);67+ return openOSD('timeline');68+ }69+ return step(dir === 'up' ? -1 : 1);70+ }71+ if (state.view === 'guide' && el.closest('.g-rows') && (dir === 'left' || dir === 'right')) {72+ const earliest = floor30(Date.now()) - 30 * MIN;73+ state.gStart = Math.max(earliest, state.gStart + (dir === 'left' ? 60 : -60) * MIN);74+ state.gTime = Math.max(state.gTime, state.gStart);75+ if (state.gTime >= state.gStart + (state.gSpan - 30) * MIN) state.gTime = state.gStart;76+ renderGuide();77+ }78+ return undefined;79+}80+81+// holdCell shows a guide program's actions, picking it first.82+function holdCell(cell) {83+ state.gRow = Number(cell.dataset.row);84+ if (cell.dataset.t) state.gTime = Number(cell.dataset.t);85+ renderGuide();86+ guideOpenActions();87+}88+89+// tappedAgain reports whether a tap lands on what was already picked: the90+// guide's, Recordings' and Podcasts' second tap does it.91+const tappedAgain = (was, now) => platform === 'touch' && was === now;@@ -0,0 +1,19 @@
1+{2+ "name": "Airwaves",3+ "short_name": "Airwaves",4+ "description": "Over-the-air television, recordings and podcasts from your Airwaves server",5+ "id": "./",6+ "start_url": "./",7+ "scope": "./",8+ "display": "fullscreen",9+ "display_override": ["fullscreen", "standalone"],10+ "orientation": "landscape",11+ "background_color": "#07080a",12+ "theme_color": "#07080a",13+ "categories": ["entertainment"],14+ "icons": [15+ { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },16+ { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },17+ { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }18+ ]19+}@@ -279,7 +279,7 @@ body.mode-guide #guide, body.mode-settings #settings, body.mode-recordings #reco
279279 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; }282-.rail-menu { display: grid; gap: 2px; }282+.rail-menu { display: grid; grid-auto-rows: max-content; gap: 2px; } /* rows whole, however many */283283 .rm-item {284284 border: 0; background: none; text-align: left; cursor: pointer; color: var(--muted);285285 display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; gap: 3px 10px;@@ -749,6 +749,66 @@ body.mouse-active .ob:hover { color: var(--paper); background: var(--ink-3); }
749749 .ob[data-act="record"] svg, .ob[data-act="live"].at svg { color: var(--red); }750750 .ob[data-act="record"].on svg { animation: pulse 1.4s ease-in-out infinite; }751751 752+/* ---------- Touch ---------- */753+/* A phone or a tablet (body.pf-touch, js/touch.js): fingers tap, swipe and754+ hold, so nothing selects as text or zooms on a double tap, the picture's755+ swipes are the app's, lists scroll under a finger, what's tapped is big756+ enough to hit, and the notch and the home bar are left clear. */757+body.pf-touch { -webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; touch-action: manipulation; overscroll-behavior: none; }758+body.pf-touch input { -webkit-user-select: text; user-select: text; }759+body.pf-touch #stage { touch-action: none; }760+body.pf-touch .g-rows { touch-action: pan-y; }761+body.pf-touch .r-grid, body.pf-touch .s-list, body.pf-touch .a-tablewrap, body.pf-touch .p-menu, body.pf-touch .r-list { overflow-y: auto; touch-action: pan-y; }762+body.pf-touch .wx-main { overflow-y: auto; touch-action: pan-y; }763+body.pf-touch #dock { top: max(7px, env(safe-area-inset-top)); }764+body.pf-touch #dock button { min-height: 40px; padding: 8px 16px; font-size: 13px; }765+body.pf-touch #dock kbd { display: none; }766+body.pf-touch .act, body.pf-touch .btn { min-height: 44px; }767+body.pf-touch .rm-item { min-height: 48px; }768+body.pf-touch .view { padding-left: max(28px, env(safe-area-inset-left)); padding-right: max(28px, env(safe-area-inset-right)); }769+body.pf-touch #banner { left: max(28px, env(safe-area-inset-left)); right: max(28px, env(safe-area-inset-right)); bottom: max(26px, env(safe-area-inset-bottom)); }770+/* Nothing to press, so no hints of keys. */771+body.pf-touch .s-hint, body.pf-touch .rd-hint, body.pf-touch .gd-hint, body.pf-touch .r-hint { display: none; }772+body.pf-touch .s-main { grid-template-rows: minmax(0, 1fr); }773+774+/* A phone on its side is short: the picture-in-picture in the rail goes775+ (it plays on, heard), the guide's top and the banner keep to the776+ essentials, and a picked item's details take less of the height. */777+@media (max-height: 520px) {778+ body.pf-touch:not(.mode-tv):not(.mode-guide) #stage { visibility: hidden; }779+ body.pf-touch .rail { padding-top: 0; }780+ body.pf-touch .view.railed { padding-top: 64px; grid-template-columns: 190px minmax(0, 1fr); gap: 20px; }781+ body.pf-touch .rm-item .rm-label { font-size: 19px; }782+ body.pf-touch .p-menu .rm-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }783+ body.pf-touch .r-detail { min-height: 0; grid-template-columns: minmax(0, 1fr) min(30%, 300px); gap: 20px; }784+ body.pf-touch .r-detail .rd-title { font-size: 30px; -webkit-line-clamp: 2; display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }785+ body.pf-touch .r-detail .rd-desc { -webkit-line-clamp: 2; }786+ body.pf-touch .p-main, body.pf-touch .r-lib.on { gap: 14px; }787+ body.pf-touch #banner { padding: 16px 20px 14px; grid-template-columns: 130px minmax(0, 1fr); gap: 8px 20px; }788+ body.pf-touch .bn-num { font-size: 64px; }789+ body.pf-touch .bn-title { font-size: 30px; margin-top: 6px; }790+ body.pf-touch .bn-desc, body.pf-touch .bn-cast, body.pf-touch .bn-logo { display: none; }791+ body.pf-touch .bn-next { margin-top: 6px; }792+ /* The guide's top clears the dock, and leaves the grid more rows. */793+ body.pf-touch { --gtop: calc(40 * var(--vh)); --pv-h: calc(var(--gtop) - 62px - 14px); --pv-w: calc(var(--pv-h) * 16 / 9); }794+ body.pf-touch .g-top { padding-top: 62px; padding-bottom: 14px; }795+ body.pf-touch.mode-guide #stage { top: 62px; }796+}797+798+/* Held upright, a phone is asked to turn sideways: the TV and its views799+ are laid out for a wide screen. */800+#rotate { display: none; }801+@media (orientation: portrait) and (max-width: 700px) {802+ body.pf-touch #rotate {803+ position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; text-align: center;804+ background: var(--ink); color: var(--paper); letter-spacing: .03em; text-transform: uppercase;805+ /* The screen's own size, whatever the page's zoom. */806+ font: 600 calc(22px / var(--zoom, 1))/1.3 var(--f-display);807+ }808+}809+.rotate-mark { width: 2.4em; height: 3.9em; margin: 0 auto 1em; border: calc(3px / var(--zoom, 1)) solid var(--amber); border-radius: .45em; animation: rotate-hint 2.4s var(--ease) infinite; }810+@keyframes rotate-hint { 0%, 30% { transform: rotate(0); } 60%, 100% { transform: rotate(-90deg); } }811+752812 /* ---------- Focus ---------- */753813 /* One mark for wherever the remote, a controller or the arrow keys are, in754814 every view: an amber ring, a warm fill and a slight lift (Android TV's@@ -0,0 +1,22 @@
1+;2+3+// The installed web app's service worker. Airwaves is live TV from a server4+// on the network, so nothing is kept to work offline: pages, scripts and5+// streams always come from the server, as they do in a browser tab. The6+// worker is here because an installable app has one, and so that when the7+// server can't be reached the app says so instead of the browser's error.8+9+self.addEventListener('install', () => self.skipWaiting());10+self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));11+12+self.addEventListener('fetch', (e) => {13+ if (e.request.mode !== 'navigate') return; // everything else as usual14+ e.respondWith(fetch(e.request).catch(() => new Response(OFFLINE, { headers: { 'Content-Type': 'text/html; charset=utf-8' } })));15+});16+17+const OFFLINE = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">18+<title>Airwaves</title>19+<body style="margin:0;min-height:100vh;display:grid;place-items:center;background:#07080a;color:#ede6d6;font:16px/1.5 system-ui,sans-serif;text-align:center">20+<div><div style="font:800 48px/1 system-ui,sans-serif;letter-spacing:.02em">AIRWAVES</div>21+<p style="color:#908a7c;max-width:28em;margin:16px auto">Can't reach the Airwaves server. Check that this device is on the same network, or on Tailscale, and that the server is running.</p>22+<button onclick="location.reload()" style="font:600 14px system-ui,sans-serif;letter-spacing:.1em;text-transform:uppercase;padding:12px 20px;border:0;border-radius:3px;background:#ffb224;color:#07080a">Try again</button></div>`;@@ -131,19 +131,29 @@
131131 132132 const query = new URLSearchParams(location.search);133133 134- // fit is the zoom that fits a 1080p screen's interface into this one:135- // the Android TV app's WebView is often 960x540 (the TV's 1080p at twice136- // the density), where 125% is what 125% is on a 1080p TV. It goes by the137- // width, which an on-screen keyboard leaves alone. Elsewhere 1.138- const fit = () => (tvApp && innerWidth > 0 ? innerWidth / 1920 : 1);134+ // A phone or a tablet: a touch screen, and no mouse or trackpad.135+ const touchScreen = !tvApp && matchMedia('(pointer: coarse)').matches && !matchMedia('(any-pointer: fine)').matches;136+137+ // The Android TV app is the TV experience, a touch screen the touch138+ // experience, a browser a desktop's; ?platform=desktop, console, tv or139+ // touch says otherwise.140+ const platform = query.get('platform') || (tvApp ? 'tv' : touchScreen ? 'touch' : 'desktop');141+142+ // fit is the zoom that fits the interface into this screen. The Android TV143+ // app's WebView is often 960x540 (the TV's 1080p at twice the density),144+ // where it shows a 1080p screen's interface, so 125% is what 125% is on a145+ // 1080p TV. A phone shows a 960-pixel-wide one, a phone on its side146+ // being 700 to 950 wide; a tablet its own. It goes by the width, which an147+ // on-screen keyboard leaves alone. Elsewhere 1.148+ const fit = () => {149+ if (tvApp && innerWidth > 0) return innerWidth / 1920;150+ if (platform === 'touch' && innerWidth > 0) return Math.min(1, innerWidth / 960);151+ return 1;152+ };139153 let lastBoot = null;140154 // Before the app's resize handler (js/settings.js), which reads it.141155 addEventListener('resize', () => { if (lastBoot) lastBoot.fit = fit(); });142156 143- // The Android TV app is the TV experience, a browser a desktop's;144- // ?platform=desktop, console or tv says otherwise.145- const platform = query.get('platform') || (tvApp ? 'tv' : 'desktop');146-147157 async function boot() {148158 const out = {149159 settings: load(), info: { name: '', mode: '', version: '', tuner: {}, dvr: false, antenna: false }, config: {},@@ -153,7 +163,7 @@
153163 // ?lite=1 or 0 turns it on or off anywhere, as AIRWAVES_LITE does.154164 lite: query.get('lite') || (tvApp ? '1' : ''),155165 // Auto is 125% across a room, as under gamescope, else 100%.156- autoScale: platform === 'desktop' ? 100 : 125,166+ autoScale: platform === 'desktop' || platform === 'touch' ? 100 : 125,157167 fit: fit(),158168 nativeZoom: false,159169 version: tvApp,@@ -316,6 +326,13 @@
316326 },317327 };318328 329+ // Installable (manifest.webmanifest): the browser wants a service worker330+ // for that, which only a secure page (https, as through Tailscale) may331+ // have.332+ if ('serviceWorker' in navigator && window.isSecureContext && !tvApp) {333+ navigator.serviceWorker.register('sw.js').catch(() => {});334+ }335+319336 // The desktop app stops its stream when it quits; a page, when it goes.320337 // A page kept for Back and Forward starts over when shown again.321338 addEventListener('pagehide', () => {@@ -42,3 +42,27 @@ func TestDist(t *testing.T) {
4242 }4343 }4444 }45+46+// TestNoNameTwice: the app's files share one scope, as plain scripts do,47+// so a name declared at the top of two of them stops the second loading.48+func TestNoNameTwice(t *testing.T) {49+ d := Dist()50+ files, err := fs.Glob(d, "js/*.js")51+ if err != nil {52+ t.Fatal(err)53+ }54+ decl := regexp.MustCompile(`(?m)^(?:const|let|var|class|function|async function) ([A-Za-z_$][\w$]*)`)55+ where := map[string]string{}56+ for _, f := range files {57+ src, err := fs.ReadFile(d, f)58+ if err != nil {59+ t.Fatal(err)60+ }61+ for _, m := range decl.FindAllStringSubmatch(string(src), -1) {62+ if prev, ok := where[m[1]]; ok {63+ t.Errorf("%s is declared in %s and %s", m[1], prev, f)64+ }65+ where[m[1]] = f66+ }67+ }68+}@@ -12,8 +12,8 @@ import (
1212 // tvFiles serves the TV interface (frontend/dist) with the prefix /tv1313 // stripped. Pages, scripts and styles are checked again on every load1414 // (no-cache, with an ETag, so it's a 304 when unchanged) and a new15-// airwavesd shows its own at once; fonts and the vendored libraries are16-// kept for a day. There are no directory listings.15+// airwavesd shows its own at once; fonts, icons and the vendored libraries16+// are kept for a day. There are no directory listings.1717 func tvFiles(fsys fs.FS) http.Handler {1818 etags := map[string]string{}1919 err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {@@ -43,7 +43,10 @@ func tvFiles(fsys fs.FS) http.Handler {
4343 return4444 }4545 w.Header().Set("ETag", etag)46- if strings.HasPrefix(name, "fonts/") || strings.HasPrefix(name, "vendor/") {46+ if strings.HasSuffix(name, ".webmanifest") {47+ w.Header().Set("Content-Type", "application/manifest+json")48+ }49+ if strings.HasPrefix(name, "fonts/") || strings.HasPrefix(name, "vendor/") || strings.HasPrefix(name, "icons/") {4750 w.Header().Set("Cache-Control", "public, max-age=86400")4851 } else {4952 w.Header().Set("Cache-Control", "no-cache")