airwavesd serves the same frontend the desktop app embeds at /tv/, and web.js stands in for the desktop app's Go side there, calling the API from the page. On the Android TV app (its user agent says AirwavesTV) the page talks to the shell's bridge, hands Back to the app, scales for the TV, names the remote's buttons in hints, and stops the stream while in the background. Reception presets come from /api/presets. Tuning stops loading the old stream first.
- commit
- 4ba3c2ac0d1574b9d4f1ce5a458537faeaf9ca8a
- parent
- c7925462e64b23d60a265e4dacf8744ff1e093da
- author
- FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
| M | README.md | 9 | +- |
| M | cmd/ | 4 | +- |
| M | deploy/ | 2 | + |
| M | deploy/ | 5 | +- |
| M | docs/ | 3 | +- |
| M | frontend/ | 94 | ++++++++- |
| M | frontend/ | 2 | + |
| M | frontend/ | 2 | + |
| A | frontend/ | 304 | ++++++++++++++++++++++++++++++ |
| A | frontend/ | 21 | ++ |
| A | frontend/ | 27 | +++ |
| M | internal/ | 15 | + |
| A | internal/ | 53 | +++++ |
| A | internal/ | 120 | ++++++++++++ |
| M | main.go | 8 | +- |
@@ -61,6 +61,13 @@ configuration only; the app never shows or edits it.
6161 In the app, open Settings and set **Airwaves server** to the server's name, `nas`6262 in these examples.6363 64+**The TV in a browser**: `airwavesd` also serves the app itself at `/tv/`, for example65+`http://nas:8089/tv/`, to any browser on the tailnet and to the Android TV app, which66+shows that page. It is the same interface over the same API (`frontend/dist/web.js`67+stands in for the desktop app's Go side), so `AIRWAVES_TOKEN` still applies: the page68+asks for the token. Favorites, hidden channels and the other app settings are kept in69+each browser.70+6471 **The tuner** is an HDHomeRun (for example a FLEX DUO) with the antenna on its coax6572 input. Both containers use host networking so Tvheadend can find it by broadcast, on6673 the LAN or on a spare Ethernet port with link-local addressing that the firewall trusts.@@ -578,7 +585,7 @@ internal/vchan custom channels: Airwaves Weather, folder, Jellyfin an
578585 internal/jellyfin Jellyfin client: sign-in, Quick Connect, browsing, stream URLs579586 deploy/, scripts/ Docker files for the server and the deploy script580587 deploy/standalone Docker Compose for custom channels only, built from source (docs/self-hosting.md)581-frontend/dist the UI: plain HTML, CSS and JS, no build step588+frontend/dist the UI: plain HTML, CSS and JS, no build step; also served at /tv/582589 ```583590 584591 `go test ./...` covers the parsers, the propagation model, recording rules against a@@ -23,6 +23,7 @@ import (
2323 "syscall"2424 "time"2525 26+ "airwaves/frontend"2627 "airwaves/internal/admin"2728 "airwaves/internal/api"2829 "airwaves/internal/hdhr"@@ -147,7 +148,7 @@ func run() error {
147148 148149 apiServer := &api.Server{149150 Backend: svc, Streams: svc.StreamHandler(), Extra: svc.WeatherHandler(), Logos: svc.LogoHandler(),150- Token: *token, AdminPassword: adminPassword,151+ Token: *token, AdminPassword: adminPassword, TV: frontend.Dist(),151152 // Where the folders are on the host, as the compose file mounts them.152153 Admin: adminPage(ctx, svc, filepath.Join(*data, "backups"), env("AIRWAVES_HOST_CHANNELS", *channels), env("AIRWAVES_HOST_MUSIC", *music)),153154 }@@ -198,6 +199,7 @@ func run() error {
198199 log.Print("admin page: /admin/, with no password: keep this server on a trusted network or set AIRWAVES_ADMIN_PASSWORD")199200 }200201 log.Printf("app API token: %s", onOrOff(*token != ""))202+ log.Print("TV app: /tv/, for browsers and the Android TV app")201203 if yt.Update {202204 log.Printf("yt-dlp: %s, kept up to date", yt.Path)203205 }@@ -11,6 +11,8 @@ WORKDIR /src
1111 COPY go.mod go.sum ./1212 COPY cmd ./cmd1313 COPY internal ./internal14+COPY frontend/embed.go ./frontend/15+COPY frontend/dist ./frontend/dist1416 RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \1517 CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \1618 go build -trimpath -ldflags="-s -w" -o /out/airwavesd ./cmd/airwavesd@@ -115,7 +115,8 @@ two; nothing runs while no one watches.
115115 this computer's address, such as `192.168.1.20` or `nas.local` (port 8089 is assumed;116116 add `:<port>` if you changed `PORT`). With `AIRWAVES_TOKEN` set, enter it as the117117 token. The guide then shows your channels. Without an antenna the app has no118- Reception or Recordings view.118+ Reception or Recordings view. A browser works too, with nothing to install: open119+ `http://<this computer>:8089/tv/`, such as `http://192.168.1.20:8089/tv/`.119120 120121 ## Jellyfin, Emby, Channels DVR and VLC121122 @@ -53,6 +53,13 @@ function setLite(on) {
5353 }5454 setLite(/Linux/.test(navigator.userAgent) && !/Android/.test(navigator.userAgent));5555 56+// The Android TV app is a WebView whose user agent ends in57+// AirwavesTV/<version>, showing this page from the server (web.js): a TV58+// with a remote, which has arrows, OK, Back and media keys but no letters59+// or pointer.60+const androidTV = /\bAirwavesTV\//.test(navigator.userAgent);61+document.body.classList.toggle('android-tv', androidTV);62+5663 // ---------- state ----------5764 const state = {5865 boot: null,@@ -333,7 +340,10 @@ function showConnect(err) {
333340 form.elements.server.value = state.settings.server || '';334341 form.elements.token.value = state.settings.token || '';335342 form.hidden = false;336- form.elements.server.focus();343+ // In a browser the server is the page's own (web.js): only a token to give.344+ const fixed = !!(state.boot && state.boot.fixedServer);345+ form.elements.server.readOnly = fixed;346+ form.elements[fixed ? 'token' : 'server'].focus();337347 form.onsubmit = async (e) => {338348 e.preventDefault();339349 const server = form.elements.server.value.trim();@@ -480,6 +490,9 @@ async function tune(ch, { quiet = false } = {}) {
480490 setTimeout(() => stage.classList.remove('tuning'), 400);481491 return renderBanner();482492 }493+ // The server ends this screen's stream as it starts the next: the494+ // picture plays out what it has, without asking for more of it.495+ if (state.hls) state.hls.stopLoad();483496 try {484497 const pb = await api().Tune(ch.number);485498 if (token !== state.tuneToken) return;@@ -557,7 +570,11 @@ function attempt(how, src) {
557570 if (err && err.name === 'NotAllowedError') {558571 video.muted = true;559572 video.play().catch(() => {});560- toast(`Started muted. Press ${keyHint('m', 'M')} for sound.`, 3500, true);573+ const m = keyHint('m', 'M');574+ if (m) return toast(`Started muted. Press ${m} for sound.`, 3500, true);575+ // A remote has no M: its next key, which lets sound play, turns it on.576+ document.addEventListener('keydown', () => { if (!state.userMuted) video.muted = false; }, { once: true, capture: true });577+ toast('Started muted. Press any key for sound.', 3500);561578 }562579 });563580 });@@ -732,13 +749,16 @@ function cancelEntry() {
732749 // Text fields keep their keys (but Esc).733750 const TEXT_INPUT = 'input:not([type=checkbox]):not([type=radio]), textarea, select';734751 735-// The media keys of a TV remote (over HDMI-CEC) or a keyboard, as the keys736-// the views handle: [key, shift].752+// The media keys of a TV remote (over HDMI-CEC, or an Android TV remote)753+// or a keyboard, as the keys the views handle: [key, shift]. Page Down is754+// channel up.737755 const KEY_ALIASES = {738756 MediaPlayPause: [' '], MediaPlay: [' '], MediaPause: [' '],739757 MediaFastForward: ['ArrowRight'], MediaRewind: ['ArrowLeft'],740758 MediaTrackNext: ['PageDown'], MediaTrackPrevious: ['PageUp'],759+ ChannelUp: ['PageDown'], ChannelDown: ['PageUp'],741760 MediaRecord: ['r'], BrowserBack: ['Escape'], GoBack: ['Escape'],761+ Guide: ['g'], Info: ['i'], ClosedCaptionToggle: ['c'], MediaLast: ['l'], MediaAudioTrack: ['v'],742762 };743763 744764 // pressKey does what a key does, for a controller button or a media key:@@ -829,6 +849,39 @@ function wireKeys() {
829849 });830850 }831851 852+// airwavesBack is the Android TV app's Back key: it does what Esc does and853+// tells whether that went back from anything. False (live TV with nothing854+// open, or still starting) lets the app close.855+window.airwavesBack = () => {856+ if (!state.settings || document.body.classList.contains('booting')) return false;857+ const el = document.activeElement;858+ const typing = !!(el && el.matches && el.matches(TEXT_INPUT));859+ const open = state.dock >= 0 || typing || !!state.entry || state.view !== 'tv'860+ || !!(state.recording && state.current) || $('#banner').classList.contains('show');861+ if (!open) return false;862+ pressKey('Escape');863+ return true;864+};865+866+// In the background (the Home key) the Android TV app's live TV stops, so867+// the server's tuner is free, and tunes again on return; a recording868+// pauses where it is.869+document.addEventListener('visibilitychange', () => {870+ if (!androidTV || !state.settings || document.body.classList.contains('booting')) return;871+ if (document.hidden && state.recording) {872+ video.pause();873+ } else if (document.hidden && state.current) {874+ state.away = true;875+ state.tuneToken++;876+ resetPlayer();877+ wxMusic.pause();878+ api().StopTV();879+ } else if (!document.hidden && state.away) {880+ state.away = false;881+ if (!state.recording) tune(state.current, { quiet: true });882+ }883+});884+832885 // ---------- dock ----------833886 // Without letter keys (a TV remote, a controller) the dock leads to every834887 // view: Left from its left edge, or Up from the top of a view whose rows@@ -1059,9 +1112,17 @@ function glyph(key, label) {
10591112 return `<img class="glyph" src="vendor/kenney-prompts/${file}${/press|dpad_none/.test(file) ? '' : '_outline'}.svg" alt="${esc(label)}">`;10601113 }10611114 1115+// The keys of the Android TV app's remote, as hints name them.1116+const REMOTE_KEYS = { Enter: 'OK', Escape: 'Back', ' ': 'Play/Pause', Arrows: 'Arrows', UpDown: 'Up, Down', LeftRight: 'Left, Right' };1117+10621118 // keyHint shows how to press key: its button after a controller was used,1063-// else its label; null when the controller has no such button.1064-const keyHint = (key, label) => (prompts.pad ? glyph(key, label) || null : esc(label));1119+// else its label (on the Android TV app, the remote's); null when the1120+// controller or the remote has no such button.1121+const keyHint = (key, label) => {1122+ if (prompts.pad) return glyph(key, label) || null;1123+ if (androidTV) return REMOTE_KEYS[key] ? esc(REMOTE_KEYS[key]) : null;1124+ return esc(label);1125+};10651126 10661127 // hints joins [key, label, what] into a line of hints, leaving out what the10671128 // controller can't do.@@ -1424,7 +1485,7 @@ function renderGuideDetail(ch, p) {
14241485 f(g, 'desc').textContent = p ? p.description || '' : ch.description || '';14251486 const live = !p || (p._s <= now && now < p._e);14261487 const when = live ? `${keyHint('Enter', 'Enter')} to watch` : `Starts in ${humanMinutes(Math.round((p._s - now) / MIN))}`;1427- f(g, 'hint').innerHTML = state.info.dvr && p && p._e > now && !ch.weather && !ch.custom && !prompts.pad ? `${when} | R record, Shift R series, N new only` : when;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;14281489 renderGuideActions(ch, p);14291490 const art = f(g, 'art');14301491 if (p && p.image) { art.style.backgroundImage = `url('${p.image}')`; art.classList.add('has'); } else { art.style.backgroundImage = ''; art.classList.remove('has'); }@@ -1889,8 +1950,11 @@ function settingsRows() {
18891950 add('Channels', { id: 'hidden', kind: 'open', label: 'Hidden channels', sub: 'In the guide, H hides a channel', value: hidden ? `${hidden} hidden` : 'None', open: 'hidden' });18901951 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 });18911952 1892- add('Server', { id: 'server', kind: 'text', label: 'Address', sub: 'Type it with a keyboard', name: 'server', text: s.server || '', placeholder: 'nas.local' });1893- add('Server', { id: 'token', kind: 'text', label: 'Token', sub: 'If the server needs one. Type it with a keyboard', name: 'token', text: s.token || '', password: true });1953+ // In a browser the server is the one the page came from (web.js).1954+ const typeIt = androidTV ? 'OK brings up the keyboard' : 'Type it with a keyboard';1955+ if (state.boot && state.boot.fixedServer) add('Server', { id: 'server', kind: 'info', label: 'Address', sub: 'Where this page comes from', value: s.server || '' });1956+ else add('Server', { id: 'server', kind: 'text', label: 'Address', sub: typeIt, name: 'server', text: s.server || '', placeholder: 'nas.local' });1957+ add('Server', { id: 'token', kind: 'text', label: 'Token', sub: `If the server needs one. ${typeIt}`, name: 'token', text: s.token || '', password: true });18941958 add('Server', {18951959 id: 'test', kind: 'action', verb: 'tests it', label: 'Test connection',18961960 value: settingsUI.tested || (info.name ? `Connected to ${info.name}` : 'Test'), run: testServer,@@ -1956,7 +2020,9 @@ function renderSettings() {
19562020 html += settingsRowHTML(r, i);19572021 });19582022 if (section) html += '</div>';1959- if (settingsUI.sub === 'controls') {2023+ 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>';2025+ } else if (settingsUI.sub === 'controls') {19602026 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>';19612027 }19622028 const keep = list.scrollTop;@@ -2003,7 +2069,7 @@ function renderSettingsHint() {
20032069 if (r && r.kind === 'switch') items.push(['Enter', 'Enter', 'switches']);20042070 if (r && r.kind === 'open') items.push(['Enter', 'Enter', 'opens']);20052071 if (r && r.kind === 'action') items.push(['Enter', 'Enter', r.verb || 'runs']);2006- if (r && r.kind === 'text') items.push(['Enter', 'Enter', 'types in it, with a keyboard']);2072+ if (r && r.kind === 'text') items.push(['Enter', 'Enter', androidTV ? 'types in it' : 'types in it, with a keyboard']);20072073 items.push(['Escape', 'Esc', settingsUI.sub ? 'back' : 'leaves Settings']);20082074 f($('#settings'), 'hint').innerHTML = hints(items);20092075 }@@ -2233,12 +2299,15 @@ async function saveServer(name, value) {
22332299 // sets it at launch, SetZoom after); elsewhere CSS zoom on the root scales22342300 // everything together, and style.css undoes it for viewport units. Auto22352301 // is 125% under gamescope, where the window is 1080p on a TV, else 100%.2302+// On the Android TV app sizes are a 1080p screen's, whatever the WebView's2303+// (fit, from web.js).22362304 const SCALES = [0, 100, 115, 130, 150]; // 0 is Auto22372305 const autoScale = () => (state.boot && state.boot.autoScale) || 100;22382306 let pageZoom = 0;22392307 22402308 function applyScale() {2241- const z = ((state.settings && state.settings.scale) || autoScale()) / 100;2309+ const fit = (state.boot && state.boot.fit) || 1;2310+ const z = (((state.settings && state.settings.scale) || autoScale()) / 100) * fit;22422311 const root = document.documentElement;22432312 const native = !!(state.boot && state.boot.nativeZoom);22442313 if (native && z !== pageZoom) {@@ -2494,6 +2563,7 @@ async function playRecording(it, from = 0) {
24942563 stage.classList.add('tuning');24952564 $('#nosignal').hidden = true;24962565 showBanner();2566+ if (state.hls) state.hls.stopLoad();24972567 try {24982568 const pb = await api().PlayRecording(it.id, from);24992569 if (token !== state.tuneToken) return;@@ -442,6 +442,8 @@ body:not(.booting) #boot { opacity: 0; visibility: hidden; }
442442 font: 400 15px var(--f-mono); color: var(--paper); outline: none; -webkit-user-select: text; user-select: text;443443 }444444 .boot-connect input:focus { border-color: var(--amber); }445+/* In a browser the address is the page's own (web.js). */446+.boot-connect input[readonly] { background: transparent; border-style: dashed; color: var(--muted); }445447 446448 #toast {447449 position: fixed; z-index: 80; top: 48px; left: 50%; transform: translate(-50%, -10px); opacity: 0; pointer-events: none;@@ -0,0 +1,304 @@
1+;2+3+// The web app. airwavesd serves this interface at /tv/ to browsers and to4+// the Android TV app, where window.go.main.App (the desktop app's Go side,5+// app.go) is this: the same calls, made with fetch to the server that6+// served the page, with the same answers and errors (a rejected promise7+// with the message, as Wails gives Go's). The desktop app's settings file8+// is localStorage here, one per server. Inside the desktop app Wails has9+// set window.go already, and this does nothing.10+//11+// The Android TV app is a WebView whose user agent ends in12+// "AirwavesTV/<version>". It may offer window.AirwavesAndroid: getServer(),13+// setServer(url) (it saves the address and loads <url>/tv/), copyText(s),14+// openUrl(u), log(level, msg) and exit(). Its Back key calls15+// window.airwavesBack() (app.js).16+(() => {17+ if (window.go) return;18+19+ const tvApp = (navigator.userAgent.match(/\bAirwavesTV\/(\S+)/) || [])[1] || '';20+ const shell = window.AirwavesAndroid || null;21+ // The server: this page's, less /tv/ (which a proxy may put under a path).22+ const base = location.origin + location.pathname.replace(/\/tv(\/.*)?$/, '');23+ const settingsKey = 'airwaves.settings';24+ const DEFAULT_PORT = 8089;25+26+ // serverURL is an address as the desktop app reads it (api.NewClient): a27+ // bare host ("nas") gets http:// and the default port.28+ function serverURL(s) {29+ let u = String(s || '').trim().replace(/\/+$/, '');30+ if (!u || u.includes('://')) return u;31+ u = `http://${u}`;32+ try {33+ const p = new URL(u);34+ if (!p.port) {35+ p.port = String(DEFAULT_PORT);36+ u = p.origin + p.pathname.replace(/\/+$/, '');37+ }38+ } catch { /* used as typed */ }39+ return u;40+ }41+42+ // The address shown for this server: the one the Android TV app was43+ // given, else the page's.44+ const serverName = () => {45+ try {46+ return (shell && shell.getServer && serverURL(shell.getServer())) || base;47+ } catch {48+ return base;49+ }50+ };51+52+ // ---------- settings (internal/store/settings.go) ----------53+ const hex = (n) => [...crypto.getRandomValues(new Uint8Array(n))].map((b) => b.toString(16).padStart(2, '0')).join('');54+ let memory = null; // when localStorage can't be used55+56+ function normalized(s) {57+ const out = { server: '', token: '', clientId: '', antenna: 'rooftop', showAll: false, lastChannel: '', captions: false, ...s };58+ if (!['indoor', 'attic', 'rooftop'].includes(out.antenna)) out.antenna = 'rooftop';59+ if (out.scale && (out.scale < 50 || out.scale > 300)) out.scale = 0;60+ // Its own client ID, so the server keeps this screen's stream apart.61+ if (!out.clientId) out.clientId = `${tvApp ? 'tv' : 'web'}-${hex(6)}`;62+ out.server = serverName();63+ return out;64+ }65+66+ function load() {67+ let s = memory;68+ try {69+ s = JSON.parse(localStorage.getItem(settingsKey) || 'null') || memory;70+ } catch { /* unreadable: defaults */ }71+ return normalized(s || {});72+ }73+74+ function save(s) {75+ const out = normalized(s);76+ memory = out;77+ try {78+ localStorage.setItem(settingsKey, JSON.stringify(out));79+ } catch { /* kept in memory for this page */ }80+ return out;81+ }82+ save(load()); // a stable client ID from the first call83+84+ // ---------- the API ----------85+ // call is one API request, with the token if one is set. Errors are86+ // their messages, worded like the desktop app's client's.87+ async function call(method, path, body, { token = load().token, ms = 120_000 } = {}) {88+ const headers = {};89+ if (body !== undefined) headers['Content-Type'] = 'application/json';90+ if (token) headers.Authorization = `Bearer ${token}`;91+ const abort = new AbortController();92+ const timer = setTimeout(() => abort.abort(), ms);93+ let resp;94+ try {95+ resp = await fetch(base + path, {96+ method, headers, cache: 'no-store', signal: abort.signal,97+ body: body === undefined ? undefined : JSON.stringify(body),98+ });99+ if (!resp.ok) {100+ let msg = '';101+ try { msg = (await resp.json()).error; } catch { /* not JSON */ }102+ throw new Error(msg || `airwaves server: HTTP ${resp.status}`);103+ }104+ return await resp.json();105+ } catch (e) {106+ // Wails rejects with Go's error text, not an Error.107+ if (e && e.name === 'AbortError') throw 'airwaves server: timed out';108+ if (e instanceof TypeError) throw `airwaves server: ${e.message}`;109+ throw String(e && e.message ? e.message : e);110+ } finally {111+ clearTimeout(timer);112+ }113+ }114+115+ // reachable tells whether a server answers, though another origin's116+ // answers can't be read here.117+ async function reachable(url) {118+ const abort = new AbortController();119+ const timer = setTimeout(() => abort.abort(), 8000);120+ try {121+ await fetch(`${url}/healthz`, { mode: 'no-cors', cache: 'no-store', signal: abort.signal });122+ return '';123+ } catch (e) {124+ return e && e.name === 'AbortError' ? 'airwaves server: timed out' : `airwaves server: can't connect to ${url}`;125+ } finally {126+ clearTimeout(timer);127+ }128+ }129+130+ const query = new URLSearchParams(location.search);131+132+ // fit is the zoom that fits a 1080p screen's interface into this one:133+ // the Android TV app's WebView is often 960x540 (the TV's 1080p at twice134+ // the density), where 125% is what 125% is on a 1080p TV. It goes by the135+ // width, which an on-screen keyboard leaves alone. Elsewhere 1.136+ const fit = () => (tvApp && innerWidth > 0 ? innerWidth / 1920 : 1);137+ let lastBoot = null;138+ // Before app.js's resize handler, which reads it.139+ addEventListener('resize', () => { if (lastBoot) lastBoot.fit = fit(); });140+141+ async function boot() {142+ const out = {143+ settings: load(), presets: [], info: { name: '', mode: '', version: '', tuner: {}, dvr: false, antenna: false }, config: {},144+ serverUrl: base,145+ view: query.get('view') || '',146+ // Lite on the Android TV app, a TV box like the Linux ones;147+ // ?lite=1 or 0 turns it on or off anywhere, as AIRWAVES_LITE does.148+ lite: query.get('lite') || (tvApp ? '1' : ''),149+ // Auto is 125% on a TV across the room, as under gamescope, else 100%.150+ autoScale: tvApp ? 125 : 100,151+ fit: fit(),152+ nativeZoom: false,153+ version: tvApp,154+ // In a browser the server is the page's own; only the Android TV app155+ // can switch to another.156+ fixedServer: !(shell && shell.setServer),157+ };158+ lastBoot = out;159+ try {160+ const [info, config, presets] = await Promise.all([161+ call('GET', '/api/info', undefined, { ms: 10_000 }),162+ call('GET', '/api/config', undefined, { ms: 10_000 }),163+ call('GET', '/api/presets', undefined, { ms: 10_000 }),164+ ]);165+ Object.assign(out, { info, config, presets });166+ out.version = out.version || info.version;167+ } catch (e) {168+ out.error = String(e);169+ }170+ return out;171+ }172+173+ const listeners = {};174+ const emit = (name, ...args) => (listeners[name] || []).forEach((cb) => cb(...args));175+ const playback = (pb) => ({ ...pb, url: base + pb.path });176+ const enc = encodeURIComponent;177+178+ const App = {179+ Boot: boot,180+181+ // SaveSettings keeps settings and returns a fresh Boot. A new server182+ // address goes to the Android TV app, which loads that server's page183+ // once it answers; this page is then on its way out.184+ async SaveSettings(s) {185+ const want = serverURL(s && s.server);186+ if (shell && shell.setServer && want && want !== serverURL(serverName())) {187+ const err = await reachable(want);188+ if (err) return { ...(await boot()), error: err };189+ shell.setServer(want);190+ return new Promise(() => {});191+ }192+ save(s);193+ return boot();194+ },195+ SetZoom() {}, // CSS zoom on the web (nativeZoom is false)196+ SetConfig: (c) => call('PUT', '/api/config', c),197+198+ // TestServer checks an address without switching to it. Another199+ // server's info can't be read from this page, so it's named by host.200+ async TestServer(server, token) {201+ const url = serverURL(server);202+ if (!url || url === base || url === serverURL(serverName())) return call('GET', '/api/info', undefined, { token, ms: 8000 });203+ const err = await reachable(url);204+ if (err) throw err;205+ return { name: new URL(url).host };206+ },207+ Scan(refresh) {208+ emit('progress', `Loading lineup and listings from ${serverName()}`);209+ return call('GET', `/api/snapshot${refresh ? '?refresh=1' : ''}`);210+ },211+ Profile: (facilityID, rf, zip) => call('GET', `/api/profile?facility=${facilityID}&rf=${rf}&zip=${enc(zip || '')}`),212+ Preview: (zip) => call('GET', `/api/preview?zip=${enc(zip || '')}`),213+ Weather: () => call('GET', '/api/weather'),214+215+ async CopyText(text) {216+ text = String(text);217+ if (shell && shell.copyText) return void shell.copyText(text);218+ if (navigator.clipboard && window.isSecureContext) {219+ try {220+ return await navigator.clipboard.writeText(text);221+ } catch (e) {222+ throw String(e && e.message ? e.message : e);223+ }224+ }225+ // Plain HTTP has no clipboard API; a selection still copies.226+ const ta = document.createElement('textarea');227+ ta.value = text;228+ ta.setAttribute('readonly', '');229+ ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';230+ document.body.append(ta);231+ ta.select();232+ let ok = false;233+ try { ok = document.execCommand('copy'); } catch { /* not allowed */ }234+ ta.remove();235+ if (!ok) throw 'copying is not allowed here';236+ return undefined;237+ },238+239+ async Tune(number) {240+ const pb = await call('POST', '/api/tune', { client: load().clientId, number });241+ save({ ...load(), lastChannel: number });242+ return playback(pb);243+ },244+ async StopTV() {245+ try { await call('POST', '/api/stop', { client: load().clientId }); } catch { /* as the desktop app: ignored */ }246+ },247+ DVR: () => call('GET', '/api/dvr'),248+ Record: (req) => call('POST', '/api/dvr/record', req),249+ DeleteRule: (id) => call('DELETE', `/api/dvr/rules/${enc(id)}`).then(() => undefined),250+ DeleteRecording: (id) => call('DELETE', `/api/dvr/recordings/${enc(id)}`).then(() => undefined),251+ async PlayRecording(id, from) {252+ if (!id) throw 'no recording selected';253+ return playback(await call('POST', '/api/dvr/play', { client: load().clientId, id, from: from || 0 }));254+ },255+ SaveProgress: (id, position, duration) => call('POST', '/api/dvr/progress', { id, position, duration }).then(() => undefined),256+ MarkWatched: (id, watched) => call('POST', '/api/dvr/progress', { id, position: 0, duration: 0, watched: !!watched }).then(() => undefined),257+ UpdateRule: (id, u) => call('PUT', `/api/dvr/rules/${enc(id)}`, u),258+ SetDVRPrefs: (p) => call('PUT', '/api/dvr/prefs', p),259+260+ async OpenURL(u) {261+ if (shell && shell.openUrl) shell.openUrl(String(u));262+ else window.open(u, '_blank', 'noopener');263+ },264+ async Log(level, msg) {265+ if (shell && shell.log) return void shell.log(String(level), String(msg));266+ (level === 'warn' || level === 'error' ? console.warn : console.info)(`ui ${level}: ${msg}`);267+ return undefined;268+ },269+ };270+ window.go = { main: { App } };271+272+ // No window to drag here.273+ const titlebar = document.getElementById('titlebar');274+ if (titlebar) titlebar.hidden = true;275+276+ // The Wails runtime calls app.js makes: events, and full screen.277+ window.runtime = {278+ EventsOn(name, cb) {279+ (listeners[name] = listeners[name] || []).push(cb);280+ return () => { listeners[name] = (listeners[name] || []).filter((x) => x !== cb); };281+ },282+ EventsOff(name) { delete listeners[name]; },283+ WindowIsFullscreen: async () => !!document.fullscreenElement,284+ WindowFullscreen() {285+ const el = document.documentElement;286+ if (el.requestFullscreen) el.requestFullscreen().catch(() => {});287+ },288+ WindowUnfullscreen() {289+ if (document.fullscreenElement) document.exitFullscreen().catch(() => {});290+ },291+ };292+293+ // The desktop app stops its stream when it quits; a page, when it goes.294+ // A page kept for Back and Forward starts over when shown again.295+ addEventListener('pagehide', () => {296+ const s = load();297+ const headers = { 'Content-Type': 'application/json' };298+ if (s.token) headers.Authorization = `Bearer ${s.token}`;299+ try {300+ fetch(`${base}/api/stop`, { method: 'POST', keepalive: true, headers, body: JSON.stringify({ client: s.clientId }) }).catch(() => {});301+ } catch { /* leaving anyway */ }302+ });303+ addEventListener('pageshow', (e) => { if (e.persisted) location.reload(); });304+})();@@ -0,0 +1,21 @@
1+// Package frontend is the TV interface: plain HTML, CSS and JS in dist,2+// with no build step. The desktop app embeds it, and airwavesd serves it3+// at /tv/ for browsers and the Android TV app.4+package frontend5+6+import (7+ "embed"8+ "io/fs"9+)10+11+//go:embed all:dist12+var dist embed.FS13+14+// Dist is the interface's files, index.html at the root.15+func Dist() fs.FS {16+ sub, err := fs.Sub(dist, "dist")17+ if err != nil {18+ panic(err) // dist is embedded, so it's there19+ }20+ return sub21+}@@ -0,0 +1,27 @@
1+package frontend2+3+import (4+ "io/fs"5+ "strings"6+ "testing"7+)8+9+// TestDist: the interface is embedded with index.html at the root, and10+// the page loads the web app's stand-in for Wails before the app.11+func TestDist(t *testing.T) {12+ d := Dist()13+ for _, name := range []string{"index.html", "app.js", "web.js", "style.css", "vendor/hls.min.js"} {14+ if _, err := fs.Stat(d, name); err != nil {15+ t.Errorf("%s: %v", name, err)16+ }17+ }18+ page, err := fs.ReadFile(d, "index.html")19+ if err != nil {20+ t.Fatal(err)21+ }22+ html := string(page)23+ web, app := strings.Index(html, `<script src="web.js">`), strings.Index(html, `<script src="app.js">`)24+ if web < 0 || app < 0 || web > app {25+ t.Errorf("web.js at %d, app.js at %d: web.js must come first", web, app)26+ }27+}@@ -11,12 +11,14 @@ import (
1111 "encoding/json"1212 "errors"1313 "io"14+ "io/fs"1415 "log"1516 "net/http"1617 "strconv"1718 "strings"1819 1920 "airwaves/internal/dvr"21+ "airwaves/internal/reception"2022 "airwaves/internal/service"2123 "airwaves/internal/stream"2224 "airwaves/internal/weather"@@ -72,6 +74,10 @@ type Server struct {
7274 // and the MCP endpoint at /admin/mcp): as HTTP Basic auth with any user7375 // name, or as "Authorization: Bearer <password>" from MCP clients.7476 AdminPassword string77+ // TV is the TV interface (frontend/dist), served at /tv/ for browsers78+ // and the Android TV app. The page needs no token; the API it calls79+ // does.80+ TV fs.FS7581 }7682 7783 type clientReq struct {@@ -98,6 +104,11 @@ func (s *Server) Handler() http.Handler {
98104 mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {99105 reply(w)(b.Config(r.Context()))100106 })107+ // The antenna presets reception is estimated for, which the desktop108+ // app has built in, for the web app.109+ mux.HandleFunc("GET /api/presets", func(w http.ResponseWriter, r *http.Request) {110+ reply(w)(reception.Presets, nil)111+ })101112 mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {102113 var c service.Config103114 if !decode(w, r, &c) {@@ -201,6 +212,10 @@ func (s *Server) Handler() http.Handler {
201212 mux.Handle("/admin/", admin)202213 mux.Handle("GET /admin", http.RedirectHandler("/admin/", http.StatusFound))203214 }215+ if s.TV != nil {216+ mux.Handle("GET /tv/", http.StripPrefix("/tv", tvFiles(s.TV)))217+ mux.Handle("GET /tv", http.RedirectHandler("/tv/", http.StatusFound))218+ }204219 // For container health checks: up, without the token.205220 mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {206221 _, _ = io.WriteString(w, "ok\n")@@ -0,0 +1,53 @@
1+package api2+3+import (4+ "crypto/sha256"5+ "encoding/hex"6+ "io/fs"7+ "log"8+ "net/http"9+ "strings"10+)11+12+// tvFiles serves the TV interface (frontend/dist) with the prefix /tv13+// stripped. Pages, scripts and styles are checked again on every load14+// (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.17+func tvFiles(fsys fs.FS) http.Handler {18+ etags := map[string]string{}19+ err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {20+ if err != nil || d.IsDir() {21+ return err22+ }23+ b, err := fs.ReadFile(fsys, p)24+ if err != nil {25+ return err26+ }27+ sum := sha256.Sum256(b)28+ etags[p] = `"` + hex.EncodeToString(sum[:8]) + `"`29+ return nil30+ })31+ if err != nil {32+ log.Printf("tv: %v", err)33+ }34+ files := http.FileServerFS(fsys)35+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {36+ name := strings.TrimPrefix(r.URL.Path, "/")37+ if name == "" {38+ name = "index.html"39+ }40+ etag, ok := etags[name]41+ if !ok {42+ http.NotFound(w, r)43+ return44+ }45+ w.Header().Set("ETag", etag)46+ if strings.HasPrefix(name, "fonts/") || strings.HasPrefix(name, "vendor/") {47+ w.Header().Set("Cache-Control", "public, max-age=86400")48+ } else {49+ w.Header().Set("Cache-Control", "no-cache")50+ }51+ files.ServeHTTP(w, r)52+ })53+}@@ -0,0 +1,120 @@
1+package api2+3+import (4+ "encoding/json"5+ "io"6+ "net/http"7+ "net/http/httptest"8+ "testing"9+ "testing/fstest"10+11+ "airwaves/internal/reception"12+)13+14+// TestTV: the TV interface is at /tv/ without the API's token, pages and15+// scripts revalidated on every load, fonts and libraries cached; the API16+// it calls still wants the token.17+func TestTV(t *testing.T) {18+ files := fstest.MapFS{19+ "index.html": {Data: []byte("<!doctype html><title>Airwaves</title>")},20+ "app.js": {Data: []byte("'use strict';")},21+ "fonts/plex.woff2": {Data: []byte("font")},22+ "vendor/hls.min.js": {Data: []byte("hls")},23+ "vendor/kenney/a.svg": {Data: []byte("<svg/>")},24+ "vendor/kenney/b.svg": {Data: []byte("<svg/>")},25+ "vendor/pretext/x.mjs": {Data: []byte("x")},26+ }27+ srv := httptest.NewServer((&Server{Backend: &fake{}, Token: "s3cret", TV: files}).Handler())28+ defer srv.Close()29+ noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}30+ get := func(path string, header ...string) (*http.Response, string) {31+ t.Helper()32+ req, _ := http.NewRequest("GET", srv.URL+path, nil)33+ for i := 0; i+1 < len(header); i += 2 {34+ req.Header.Set(header[i], header[i+1])35+ }36+ resp, err := noFollow.Do(req)37+ if err != nil {38+ t.Fatal(err)39+ }40+ defer resp.Body.Close()41+ body, _ := io.ReadAll(resp.Body)42+ return resp, string(body)43+ }44+45+ if resp, _ := get("/tv"); resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/tv/" {46+ t.Errorf("/tv: %d to %q", resp.StatusCode, resp.Header.Get("Location"))47+ }48+ resp, body := get("/tv/")49+ if resp.StatusCode != 200 || body != "<!doctype html><title>Airwaves</title>" {50+ t.Fatalf("/tv/: %d %q", resp.StatusCode, body)51+ }52+ if cc, ct := resp.Header.Get("Cache-Control"), resp.Header.Get("Content-Type"); cc != "no-cache" || ct != "text/html; charset=utf-8" {53+ t.Errorf("/tv/: Cache-Control %q, Content-Type %q", cc, ct)54+ }55+ etag := resp.Header.Get("ETag")56+ if etag == "" {57+ t.Fatal("/tv/: no ETag")58+ }59+ if resp, _ := get("/tv/", "If-None-Match", etag); resp.StatusCode != http.StatusNotModified {60+ t.Errorf("/tv/ again: %d, want 304", resp.StatusCode)61+ }62+ if resp, body := get("/tv/app.js"); resp.StatusCode != 200 || body != "'use strict';" || resp.Header.Get("Cache-Control") != "no-cache" {63+ t.Errorf("app.js: %d %q %q", resp.StatusCode, body, resp.Header.Get("Cache-Control"))64+ }65+ if resp, _ := get("/tv/app.js", "If-None-Match", etag); resp.StatusCode != 200 {66+ t.Errorf("app.js with index.html's ETag: %d", resp.StatusCode)67+ }68+ for _, path := range []string{"/tv/fonts/plex.woff2", "/tv/vendor/hls.min.js", "/tv/vendor/kenney/a.svg"} {69+ if resp, _ := get(path); resp.StatusCode != 200 || resp.Header.Get("Cache-Control") != "public, max-age=86400" {70+ t.Errorf("%s: %d %q", path, resp.StatusCode, resp.Header.Get("Cache-Control"))71+ }72+ }73+ for _, path := range []string{"/tv/vendor/", "/tv/vendor/kenney/", "/tv/missing.js"} {74+ if resp, _ := get(path); resp.StatusCode != http.StatusNotFound {75+ t.Errorf("%s: %d, want 404", path, resp.StatusCode)76+ }77+ }78+ if resp, _ := get("/tv/index.html"); resp.StatusCode != http.StatusMovedPermanently || resp.Header.Get("Location") != "./" {79+ t.Errorf("/tv/index.html: %d to %q", resp.StatusCode, resp.Header.Get("Location"))80+ }81+ if resp, _ := get("/api/info"); resp.StatusCode != http.StatusUnauthorized {82+ t.Errorf("/api/info without the token: %d", resp.StatusCode)83+ }84+85+ // Without the interface there's nothing at /tv/.86+ bare := httptest.NewServer((&Server{Backend: &fake{}}).Handler())87+ defer bare.Close()88+ if resp, err := http.Get(bare.URL + "/tv/"); err != nil || resp.StatusCode != http.StatusNotFound {89+ t.Errorf("no TV: %v %v", resp, err)90+ }91+}92+93+// TestPresets: the web app gets the antenna presets the desktop app has94+// built in.95+func TestPresets(t *testing.T) {96+ srv := httptest.NewServer((&Server{Backend: &fake{}, Token: "s3cret"}).Handler())97+ defer srv.Close()98+ req, _ := http.NewRequest("GET", srv.URL+"/api/presets", nil)99+ resp, err := http.DefaultClient.Do(req)100+ if err != nil {101+ t.Fatal(err)102+ }103+ resp.Body.Close()104+ if resp.StatusCode != http.StatusUnauthorized {105+ t.Errorf("without the token: %d", resp.StatusCode)106+ }107+ req.Header.Set("Authorization", "Bearer s3cret")108+ if resp, err = http.DefaultClient.Do(req); err != nil {109+ t.Fatal(err)110+ }111+ defer resp.Body.Close()112+ var got []reception.Preset113+ if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {114+ t.Fatal(err)115+ }116+ if len(got) != len(reception.Presets) || got[0].Name != "indoor" || got[2].Name != "rooftop" || got[2].Label != reception.Presets[2].Label ||117+ got[1].HeightM != reception.Presets[1].HeightM || got[0].GainDBi["UHF"] != reception.Presets[0].GainDBi["UHF"] {118+ t.Errorf("presets = %+v", got)119+ }120+}@@ -3,7 +3,6 @@
33 package main44 55 import (6- "embed"76 "log"87 "os"98 "strconv"@@ -14,13 +13,12 @@ import (
1413 "github.com/wailsapp/wails/v2/pkg/options/assetserver"1514 "github.com/wailsapp/wails/v2/pkg/options/linux"1615 "github.com/wailsapp/wails/v2/pkg/options/mac"16+17+ "airwaves/frontend"1718 )1819 1920 const version = "0.1.0"2021 21-//go:embed all:frontend/dist22-var assets embed.FS23-2422 func main() {2523 app := NewApp()2624 setPageZoom(float64(app.scale()) / 100)@@ -34,7 +32,7 @@ func main() {
3432 // Filling the screen, or 16:9 under gamescope (see window).3533 WindowStartState: state,3634 BackgroundColour: &options.RGBA{R: 7, G: 8, B: 10, A: 255},37- AssetServer: &assetserver.Options{Assets: assets},35+ AssetServer: &assetserver.Options{Assets: frontend.Dist()},3836 OnStartup: app.startup,3937 OnShutdown: app.shutdown,4038 Bind: []any{app},