1;23// 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() (js/input.js).16(() => {17 if (window.go) return;1819 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;2526 // 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 }4142 // 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 };5152 // ---------- 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 used5556 function normalized(s) {57 const out = { server: '', token: '', clientId: '', lastChannel: '', captions: false, ...s };58 // Settings of reception estimates, from before the measured lineup.59 delete out.antenna;60 delete out.showAll;61 if (out.scale && (out.scale < 50 || out.scale > 300)) out.scale = 0;62 // Its own client ID, so the server keeps this screen's stream apart.63 if (!out.clientId) out.clientId = `${tvApp ? 'tv' : 'web'}-${hex(6)}`;64 out.server = serverName();65 return out;66 }6768 function load() {69 let s = memory;70 try {71 s = JSON.parse(localStorage.getItem(settingsKey) || 'null') || memory;72 } catch { /* unreadable: defaults */ }73 return normalized(s || {});74 }7576 function save(s) {77 const out = normalized(s);78 memory = out;79 try {80 localStorage.setItem(settingsKey, JSON.stringify(out));81 } catch { /* kept in memory for this page */ }82 return out;83 }84 save(load()); // a stable client ID from the first call8586 // ---------- the API ----------87 // call is one API request, with the token if one is set. Errors are88 // their messages, worded like the desktop app's client's.89 async function call(method, path, body, { token = load().token, ms = 120_000 } = {}) {90 const headers = {};91 if (body !== undefined) headers['Content-Type'] = 'application/json';92 if (token) headers.Authorization = `Bearer ${token}`;93 const abort = new AbortController();94 const timer = setTimeout(() => abort.abort(), ms);95 let resp;96 try {97 resp = await fetch(base + path, {98 method, headers, cache: 'no-store', signal: abort.signal,99 body: body === undefined ? undefined : JSON.stringify(body),100 });101 if (!resp.ok) {102 let msg = '';103 try { msg = (await resp.json()).error; } catch { /* not JSON */ }104 throw new Error(msg || `airwaves server: HTTP ${resp.status}`);105 }106 return await resp.json();107 } catch (e) {108 // Wails rejects with Go's error text, not an Error.109 if (e && e.name === 'AbortError') throw 'airwaves server: timed out';110 if (e instanceof TypeError) throw `airwaves server: ${e.message}`;111 throw String(e && e.message ? e.message : e);112 } finally {113 clearTimeout(timer);114 }115 }116117 // reachable tells whether a server answers, though another origin's118 // answers can't be read here.119 async function reachable(url) {120 const abort = new AbortController();121 const timer = setTimeout(() => abort.abort(), 8000);122 try {123 await fetch(`${url}/healthz`, { mode: 'no-cors', cache: 'no-store', signal: abort.signal });124 return '';125 } catch (e) {126 return e && e.name === 'AbortError' ? 'airwaves server: timed out' : `airwaves server: can't connect to ${url}`;127 } finally {128 clearTimeout(timer);129 }130 }131132 const query = new URLSearchParams(location.search);133134 // A phone or a tablet: its main pointer is a finger. Not that it has no135 // fine one besides: a Galaxy phone's S Pen is one, and a tablet's136 // keyboard case may bring a trackpad.137 const touchScreen = !tvApp && matchMedia('(pointer: coarse)').matches;138139 // The Android TV app is the TV experience, a touch screen the touch140 // experience, a browser a desktop's; ?platform=desktop, console, tv or141 // touch says otherwise.142 const platform = query.get('platform') || (tvApp ? 'tv' : touchScreen ? 'touch' : 'desktop');143144 // fit is the zoom that fits the interface into this screen. The Android TV145 // app's WebView is often 960x540 (the TV's 1080p at twice the density),146 // where it shows a 1080p screen's interface, so 125% is what 125% is on a147 // 1080p TV. A phone shows a 960-pixel-wide one, a phone on its side148 // being 700 to 950 wide; a tablet its own. It goes by the width, which an149 // on-screen keyboard leaves alone. Elsewhere 1.150 const fit = () => {151 if (tvApp && innerWidth > 0) return innerWidth / 1920;152 if (platform === 'touch' && innerWidth > 0) return Math.min(1, innerWidth / 960);153 return 1;154 };155 let lastBoot = null;156 // Before the app's resize handler (js/settings.js), which reads it.157 addEventListener('resize', () => { if (lastBoot) lastBoot.fit = fit(); });158159 async function boot() {160 const out = {161 settings: load(), info: { name: '', mode: '', version: '', tuner: {}, dvr: false, antenna: false }, config: {},162 serverUrl: base,163 view: query.get('view') || '',164 // Lite on the Android TV app, a TV box like the Linux ones;165 // ?lite=1 or 0 turns it on or off anywhere, as AIRWAVES_LITE does.166 lite: query.get('lite') || (tvApp ? '1' : ''),167 // Auto is 125% across a room, as under gamescope, else 100%.168 autoScale: platform === 'desktop' || platform === 'touch' ? 100 : 125,169 fit: fit(),170 nativeZoom: false,171 version: tvApp,172 platform,173 // In a browser the server is the page's own; only the Android TV app174 // can switch to another.175 fixedServer: !(shell && shell.setServer),176 };177 lastBoot = out;178 try {179 const [info, config] = await Promise.all([180 call('GET', '/api/info', undefined, { ms: 10_000 }),181 call('GET', '/api/config', undefined, { ms: 10_000 }),182 ]);183 Object.assign(out, { info, config });184 out.version = out.version || info.version;185 } catch (e) {186 out.error = String(e);187 }188 return out;189 }190191 const listeners = {};192 const emit = (name, ...args) => (listeners[name] || []).forEach((cb) => cb(...args));193 const playback = (pb) => ({ ...pb, url: base + pb.path });194 const enc = encodeURIComponent;195196 const App = {197 Boot: boot,198199 // SaveSettings keeps settings and returns a fresh Boot. A new server200 // address goes to the Android TV app, which loads that server's page201 // once it answers; this page is then on its way out.202 async SaveSettings(s) {203 const want = serverURL(s && s.server);204 if (shell && shell.setServer && want && want !== serverURL(serverName())) {205 const err = await reachable(want);206 if (err) return { ...(await boot()), error: err };207 shell.setServer(want);208 return new Promise(() => {});209 }210 save(s);211 return boot();212 },213 SetZoom() {}, // CSS zoom on the web (nativeZoom is false)214 SetConfig: (c) => call('PUT', '/api/config', c),215216 // TestServer checks an address without switching to it. Another217 // server's info can't be read from this page, so it's named by host.218 async TestServer(server, token) {219 const url = serverURL(server);220 if (!url || url === base || url === serverURL(serverName())) return call('GET', '/api/info', undefined, { token, ms: 8000 });221 const err = await reachable(url);222 if (err) throw err;223 return { name: new URL(url).host };224 },225 Scan(refresh) {226 emit('progress', `Loading lineup and listings from ${serverName()}`);227 return call('GET', `/api/snapshot${refresh ? '?refresh=1' : ''}`);228 },229 // What the tuners measured, and Measure now.230 Signal: () => call('GET', '/api/signal', undefined, { ms: 15_000 }),231 Measure: () => call('POST', '/api/signal/measure', {}, { ms: 15_000 }),232 ScanChannels: () => call('POST', '/api/tuner/scan', {}, { ms: 15_000 }),233 Weather: () => call('GET', '/api/weather'),234235 async CopyText(text) {236 text = String(text);237 if (shell && shell.copyText) return void shell.copyText(text);238 if (navigator.clipboard && window.isSecureContext) {239 try {240 return await navigator.clipboard.writeText(text);241 } catch (e) {242 throw String(e && e.message ? e.message : e);243 }244 }245 // Plain HTTP has no clipboard API; a selection still copies.246 const ta = document.createElement('textarea');247 ta.value = text;248 ta.setAttribute('readonly', '');249 ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';250 document.body.append(ta);251 ta.select();252 let ok = false;253 try { ok = document.execCommand('copy'); } catch { /* not allowed */ }254 ta.remove();255 if (!ok) throw 'copying is not allowed here';256 return undefined;257 },258259 async Tune(number) {260 const pb = await call('POST', '/api/tune', { client: load().clientId, number });261 save({ ...load(), lastChannel: number });262 return playback(pb);263 },264 async StopTV() {265 try { await call('POST', '/api/stop', { client: load().clientId }); } catch { /* as the desktop app: ignored */ }266 },267 // Keeps a paused stream from being stopped as idle; false once it's gone.268 Heartbeat: () => call('POST', '/api/heartbeat', { client: load().clientId }).then((r) => !!(r && r.stream)),269 // How long a channel change took, for the server's admin page.270 ReportTune: (m) => call('POST', '/api/metrics', m).then(() => undefined),271 DVR: () => call('GET', '/api/dvr'),272 Record: (req) => call('POST', '/api/dvr/record', req),273 DeleteRule: (id) => call('DELETE', `/api/dvr/rules/${enc(id)}`).then(() => undefined),274 DeleteRecording: (id) => call('DELETE', `/api/dvr/recordings/${enc(id)}`).then(() => undefined),275 StopRecording: (id) => call('POST', `/api/dvr/recordings/${enc(id)}/stop`, {}).then(() => undefined),276 ExtendRecording: (id, minutes) => call('POST', `/api/dvr/recordings/${enc(id)}/extend`, { minutes }).then((r) => r.until),277 async PlayRecording(id, from) {278 if (!id) throw 'no recording selected';279 return playback(await call('POST', '/api/dvr/play', { client: load().clientId, id, from: from || 0 }));280 },281 SaveProgress: (id, position, duration) => call('POST', '/api/dvr/progress', { id, position, duration }).then(() => undefined),282 MarkWatched: (id, watched) => call('POST', '/api/dvr/progress', { id, position: 0, duration: 0, watched: !!watched }).then(() => undefined),283 UpdateRule: (id, u) => call('PUT', `/api/dvr/rules/${enc(id)}`, u),284 SetDVRPrefs: (p) => call('PUT', '/api/dvr/prefs', p),285286 // The Podcasts app.287 Podcasts: () => call('GET', '/api/podcasts'),288 RefreshPodcasts: () => call('POST', '/api/podcasts/refresh'),289 SearchPodcasts: (q) => call('GET', `/api/podcasts/search?q=${enc(q)}`),290 AddPodcast: (url, minMinutes) => call('POST', '/api/podcasts/shows', { url, minMinutes: minMinutes || 0 }),291 RemovePodcast: (id) => call('DELETE', `/api/podcasts/shows/${enc(id)}`).then(() => undefined),292 async PlayEpisode(id, from) {293 if (!id) throw 'no episode selected';294 return playback(await call('POST', '/api/podcasts/play', { client: load().clientId, id, from: from || 0 }));295 },296 EpisodeProgress: (id, position, duration, watched) => call('POST', '/api/podcasts/progress',297 { id, position: position || 0, duration: duration || 0, watched: !!watched }).then(() => undefined),298299 async OpenURL(u) {300 if (shell && shell.openUrl) shell.openUrl(String(u));301 else window.open(u, '_blank', 'noopener');302 },303 async Log(level, msg) {304 if (shell && shell.log) return void shell.log(String(level), String(msg));305 (level === 'warn' || level === 'error' ? console.warn : console.info)(`ui ${level}: ${msg}`);306 return undefined;307 },308 };309 window.go = { main: { App } };310311 // No window to drag here.312 const titlebar = document.getElementById('titlebar');313 if (titlebar) titlebar.hidden = true;314315 // The Wails runtime calls the app makes: events, and full screen.316 window.runtime = {317 EventsOn(name, cb) {318 (listeners[name] = listeners[name] || []).push(cb);319 return () => { listeners[name] = (listeners[name] || []).filter((x) => x !== cb); };320 },321 EventsOff(name) { delete listeners[name]; },322 WindowIsFullscreen: async () => !!document.fullscreenElement,323 WindowFullscreen() {324 const el = document.documentElement;325 if (el.requestFullscreen) el.requestFullscreen().catch(() => {});326 },327 WindowUnfullscreen() {328 if (document.fullscreenElement) document.exitFullscreen().catch(() => {});329 },330 };331332 // Installable (manifest.webmanifest): the browser wants a service worker333 // for that, which only a secure page (https, as through Tailscale) may334 // have.335 if ('serviceWorker' in navigator && window.isSecureContext && !tvApp) {336 navigator.serviceWorker.register('sw.js').catch(() => {});337 }338339 // The desktop app stops its stream when it quits; a page, when it goes.340 // A page kept for Back and Forward starts over when shown again.341 addEventListener('pagehide', () => {342 const s = load();343 const headers = { 'Content-Type': 'application/json' };344 if (s.token) headers.Authorization = `Bearer ${s.token}`;345 try {346 fetch(`${base}/api/stop`, { method: 'POST', keepalive: true, headers, body: JSON.stringify({ client: s.clientId }) }).catch(() => {});347 } catch { /* leaving anyway */ }348 });349 addEventListener('pageshow', (e) => { if (e.persisted) location.reload(); });350})();