1;23// State, data from the server, boot, and switching views.45// ---------- helpers ----------6const $ = (s, r = document) => r.querySelector(s);7const $$ = (s, r = document) => [...r.querySelectorAll(s)];8const f = (root, name) => root.querySelector(`[data-f="${name}"]`);9const api = () => window.go && window.go.main && window.go.main.App;10const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));11const MIN = 60_000;12const floor30 = (t) => Math.floor(t / (30 * MIN)) * 30 * MIN;13// One formatter for every time shown: toLocaleTimeString makes a new one14// each call, which is most of the guide's build time.15const clockFormat = new Intl.DateTimeFormat([], { hour: 'numeric', minute: '2-digit' });16const clock = (t) => clockFormat.format(new Date(t));17const COMPASS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];18const compass = (deg) => COMPASS[Math.round(deg / 22.5) % 16];1920function log(level, msg) {21 try { api().Log(level, String(msg)); } catch { /* not in Wails */ }22}2324// ---------- signal ----------25// Everything shown of reception was measured by the server's tuners (its26// /api/signal): per RF channel, the latest reading and the latest stretch27// of readings (recent: the last Measure now, or the last while something28// watched it), with lows and means. The stretch is what's shown: a single29// reading of a marginal signal jumps around. The HDHomeRun reports30// strength, quality (its signal-to-noise quality) and symbol quality (the31// share of data arriving whole or corrected) as percentages; the errors in32// what arrived the server counts itself.33const SIG_COLOR = { lock: 'var(--amber)', part: 'var(--amber-2)', off: 'var(--red)', none: 'var(--faint)' };3435// sigOf sums up a measurement ({ signal, recent }, of a channel or an RF36// channel): its state ('lock' throughout, 'part' some of the time, 'off'37// none, 'none' never measured), bars to show (0 to 5, from the measured38// quality), a label and the details.39function sigOf(m) {40 const r = m && m.recent;41 const sig = m && m.signal;42 if (!r && !sig) return { state: 'none', bars: 0, label: 'Not measured yet', color: SIG_COLOR.none, quality: '', strength: '', errors: null, at: 0, source: '', lockedPct: 0 };43 const lockedPct = r ? r.lockedPct : sig.lock ? 100 : 0;44 const state = lockedPct >= 100 ? 'lock' : lockedPct > 0 ? 'part' : 'off';45 // From the stretch, its means; else the one reading.46 const val = (key) => (r ? (r[key] ? r[key].avg : null) : sig[key]);47 const src = r || sig;48 const q = val('qualityPct');49 const strengthPct = val('strengthPct');50 const symbolPct = val('symbolPct');51 let bars = 0;52 if (state !== 'off') bars = q == null ? 3 : q >= 70 ? 5 : q >= 55 ? 4 : q >= 40 ? 3 : q >= 25 ? 2 : q > 0 ? 1 : 0;53 const quality = q != null ? `${Math.round(q)}%` : '';54 const strength = strengthPct != null ? `${Math.round(strengthPct)}%` : '';55 const symbol = symbolPct != null ? `${Math.round(symbolPct)}%` : '';56 const errors = sig && sig.errorsPerSec != null ? sig.errorsPerSec : null;57 const label = state === 'off' ? 'No lock'58 : state === 'part' ? `Locked ${Math.round(lockedPct)}% of the time`59 : quality ? `Quality ${quality}` : 'Locked';60 return { state, bars, label, color: SIG_COLOR[state], quality, strength, symbol, errors, at: Date.parse(src.to || src.at), source: src.source, lockedPct };61}6263// meter draws a measurement's bars.64function meter(s, big) {65 let html = '';66 for (let i = 1; i <= 5; i++) html += `<i class="${i <= s.bars ? 'on' : ''}"></i>`;67 return `<span class="meter${big ? ' big' : ''} m-${s.state}" style="--c:${s.color}">${html}</span>`;68}6970// ago says how long ago a time was, roughly.71function ago(t) {72 const m = Math.round((Date.now() - t) / MIN);73 if (m < 1) return 'just now';74 if (m < 60) return `${m} min ago`;75 const h = Math.round(m / 60);76 if (h < 24) return `${h} h ago`;77 return new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' });78}7980// agoShort is ago for a table: "10 min", "3 h", "Oct 3".81function agoShort(t) {82 const m = Math.round((Date.now() - t) / MIN);83 if (m < 1) return 'now';84 if (m < 60) return `${m} min`;85 if (m < 24 * 60) return `${Math.round(m / 60)} h`;86 return new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' });87}8889// sigDetail is a measurement's details in words: "strength 82%, 1890// errors a second, 5 min ago".91function sigDetail(s) {92 if (s.state === 'none') return 'Measure now in Reception reads it';93 const parts = [];94 if (s.state !== 'lock' && s.quality && s.state !== 'off') parts.push(`quality ${s.quality}`);95 if (s.strength) parts.push(`strength ${s.strength}`);96 if (s.symbol && s.state !== 'off' && s.symbol !== '100%') parts.push(`symbol quality ${s.symbol}`);97 if (s.errors != null && s.state !== 'off') parts.push(s.errors >= 1 ? `${Math.round(s.errors)} errors a second` : 'no errors');98 parts.push(`${ago(s.at)}${s.source === 'active' ? ', while tuned' : ''}`);99 return parts.join(', ');100}101102// trouble is a measurement that explains a bad picture: no lock, a lock103// that comes and goes, or a stream breaking up (5 errors a second or more).104const trouble = (s) => s.state === 'off' || s.state === 'part' || (s.errors != null && s.errors >= 5);105106let toastTimer = 0;107function toast(msg, ms = 3500, html = false) {108 const t = $('#toast');109 if (html) t.innerHTML = msg; else t.textContent = msg;110 t.classList.add('show');111 clearTimeout(toastTimer);112 toastTimer = setTimeout(() => t.classList.remove('show'), ms);113}114115// Lite mode is for Linux, where the app is WebKitGTK on a TV box: the same116// look without the blurs, film grain, big soft shadows and animations that117// cost a weak CPU and GPU most (style.css), and less video kept in memory.118// AIRWAVES_LITE=1 or 0 (in Boot) turns it on or off anywhere.119let lite = false;120function setLite(on) {121 lite = on;122 document.body.classList.toggle('lite', on);123}124setLite(/Linux/.test(navigator.userAgent) && !/Android/.test(navigator.userAgent));125126// The Android TV app is a WebView whose user agent ends in127// AirwavesTV/<version>, showing this page from the server (web.js). This128// says only what's the WebView's own (its keyboard, the Home key); what a129// TV is like is the platform's.130const androidTV = /\bAirwavesTV\//.test(navigator.userAgent);131132// ---------- platform ----------133// Airwaves is three experiences, each whole and kept apart (body.pf-*,134// and the checks of platform):135// desktop: the app in a window on a Mac, Linux or Windows, or a browser.136// A mouse and a keyboard: hints name keys, letters are shortcuts, the137// dock shows with the pointer.138// console: the Linux app under gamescope, SteamOS's Game Mode on a TV.139// A controller: hints show its buttons from the start, sized for140// across a room.141// tv: the Android TV app. A remote with arrows, OK, Back and media keys142// but no letters or pointer: hints name the remote's keys, holding OK143// 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.147// The app says which it is (Boot's platform: AIRWAVES_PLATFORM, else a148// 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 say150// otherwise on this device. What's pressed still counts: a controller on a151// desktop brings its buttons to the hints, a keyboard on a TV its keys.152const PLATFORMS = [['desktop', 'Desktop'], ['console', 'Console'], ['tv', 'TV'], ['touch', 'Touch']];153const platformKey = 'airwaves.platform';154let platform = 'desktop';155let detectedPlatform = 'desktop';156// onRemote: the hints and the ways to do things are a TV remote's.157const onRemote = () => platform === 'tv' && !prompts.pad;158// onKeys: they're a keyboard's, letters and all.159const onKeys = () => (platform === 'desktop' || platform === 'console') && !prompts.pad;160161function applyPlatform(detected) {162 if (detected) detectedPlatform = PLATFORMS.some(([k]) => k === detected) ? detected : 'desktop';163 const forced = localStorage.getItem(platformKey) || '';164 const next = PLATFORMS.some(([k]) => k === forced) ? forced : detectedPlatform;165 for (const [k] of PLATFORMS) document.body.classList.toggle(`pf-${k}`, k === next);166 const changed = next !== platform;167 platform = next;168 // A console starts with its controller's buttons in the hints.169 if (platform === 'console') setPrompts(true);170 else if (changed) setPrompts(false);171 return changed;172}173174// ---------- state ----------175const state = {176 boot: null,177 settings: null,178 report: null,179 guide: null,180 byFacility: new Map(), // facilityId -> nearest station181 bySite: new Map(), // "facilityId:rf" -> station182 antenna: null, // the tuner and its RF channels, as last measured (/api/signal)183 antennaChans: [], // the tuner's channels, hidden ones too184 byRF: new Map(), // RF channel -> what's known of it (antenna.muxes)185 sigStamp: 0, // counts signal updates, for redraws186 lineup: [],187 custom: [], // the server's own channels: the weather channel, folder, Jellyfin and YouTube channels188 current: null,189 previous: null,190 note: '',191 view: 'tv',192 tuneToken: 0,193 hls: null,194 entry: '',195 entryTimer: 0,196 bannerTimer: 0,197 guideFilter: 'all',198 gStart: 0,199 gSpan: 150,200 gRow: 0,201 gTime: 0,202 aRF: 0, // the RF channel picked in Reception203 info: null, // backend: local engine or a server204 config: null, // location the lineup is built for205 dvr: null, // recording state from the server206 podcasts: null, // the Podcasts app's shows and episodes207 dvrKeys: new Map(), // "7.1@1791058239" -> upcoming item208 recPick: '', // the airing whose Record is open to its choices209 recording: null, // recording being played instead of live TV210 captionsFor: null, // captions turned off or on for one program: { key, on }211 recTab: 'library',212 recSelBy: null,213 dock: -1, // dock button picked with the arrow keys, or -1214};215216const video = $('#video');217const stage = $('#stage');218219// ---------- data ----------220async function scan(refresh) {221 const snap = await api().Scan(refresh);222 state.report = snap.report;223 state.guide = snap.guide || { programs: {} };224 // The antenna channels are the tuner's: those its scan found, described225 // from the FCC's records and the listings. Their keys (for favorites and226 // hidden channels) carry the listings' call sign where there is one227 // ("2.1|KWGNDT"), as keys always have, else the station's.228 state.antennaChans = (snap.antenna ? snap.antenna.channels : snap.report.channels || [])229 .map((c) => ({ ...c, key: `${c.number}|${c.guideCallSign || c.callSign}` }));230 if (snap.antenna) applySignal(snap.antenna);231 // The server's own channels, with the details set on its admin page. The232 // weather channel plays here, from the WeatherStar display; the others'233 // schedules join the guide like a station's, under their own guide id,234 // their category counting as a genre of each program. A server from235 // before the weather channel was listed here sends no list, or channels236 // without a kind: then the weather channel is the old WX.237 const listed = Array.isArray(snap.custom) && snap.custom.every((c) => c.kind);238 state.custom = (snap.custom || []).map((c) => {239 const ch = ownChannel(c);240 if (!ch.weather) {241 const genres = categoryGenres(ch.category);242 state.guide.programs[ch.guideId] = (c.programs || []).map((p) => (genres.length ? { ...p, genres: [...new Set([...(p.genres || []), ...genres])] } : p));243 }244 return ch;245 });246 if (!listed && state.info.weatherStar && !state.custom.some((c) => c.weather)) state.custom.unshift(OLD_WX);247 indexData();248 await migrateKeys();249 buildLineup();250}251252function indexData() {253 state.byFacility.clear();254 state.bySite.clear();255 const stations = [...state.report.stations].sort((a, b) => a.distanceKm - b.distanceKm);256 for (const s of stations) {257 if (!state.byFacility.has(s.facilityId)) state.byFacility.set(s.facilityId, s);258 state.bySite.set(`${s.facilityId}:${s.rfChannel}`, s);259 }260 const progs = (state.guide && state.guide.programs) || {};261 for (const list of Object.values(progs)) {262 for (const p of list) { p._s = Date.parse(p.start); p._e = Date.parse(p.end); }263 }264}265266// ---------- the server's own channels ----------267// Keys (for favorites and hidden channels) stay with a channel through a new268// number or name: the weather channel has one key, and a custom channel's269// carries its name as well as its number (migrateKeys follows either).270const WEATHER_KEY = 'weather';271272// A custom channel's category counts as these genres for its programs, so273// the guide's filters find it; categories with no filter of their own get274// one while a channel has them (guideFilters).275const CATEGORY_GENRES = {276 Sports: ['sports'], Movies: ['movie'], News: ['news'], Family: ['family'], Kids: ['kids', 'family'],277 Music: ['music'], Pets: ['pets'], Gaming: ['gaming'], Documentary: ['documentary'], Weather: ['weather'],278};279const categoryGenres = (category) => CATEGORY_GENRES[category] || [];280281// ownChannel is a channel the server lists as its own, for the lineup. Its282// call sign is the short label (call); its name stands where a station's283// network does.284function ownChannel(c) {285 const [major, minor] = c.number.split('.').map(Number);286 const weather = c.kind === 'weather';287 return {288 key: weather ? WEATHER_KEY : `${c.number}|custom|${c.name}`,289 number: c.number, major: major || 0, minor: minor || 0, name: c.name, network: c.name,290 call: c.callSign || '', callSign: c.callSign || '', baseCall: c.callSign || '',291 kind: c.kind || 'folder', category: c.category || 'Other', description: c.description || '',292 logo: c.logo ? state.boot.serverUrl + c.logo : '',293 own: true, weather, custom: !weather, guideId: weather ? '' : `custom:${c.number}`,294 };295}296297// OLD_WX is the weather channel of a server from before it listed it.298const OLD_WX = {299 key: WEATHER_KEY, number: 'WX', major: 0, minor: 0, name: 'Airwaves Weather', network: 'Local Forecast',300 call: 'WX', callSign: 'WX', baseCall: 'WX', kind: 'weather', category: 'Weather', description: '', logo: '',301 own: true, weather: true, custom: false, guideId: '',302};303304const weatherChannel = () => state.custom.find((c) => c.weather) || null;305306// migrateKeys moves saved favorites and hidden channels to where the307// server's own channels are now: the weather channel's from the old WX key,308// and a custom channel's to its current number and name (found by name, or309// else by number). An antenna channel's whose call sign changed moves to310// the one channel with its number. Keys of channels not listed now are kept311// for when they are. A saved last channel of WX becomes the weather312// channel's number, and one no longer in use the new number of the custom313// channel a saved key names with it.314async function migrateKeys() {315 const s = state.settings;316 const custom = state.custom.filter((c) => !c.weather);317 const known = new Set([...state.custom, ...state.antennaChans].map((c) => c.key));318 const fix = (k) => {319 if (k === 'WX|WX') return WEATHER_KEY;320 if (known.has(k)) return k;321 const [num, kind, ...rest] = String(k).split('|');322 if (kind !== 'custom') {323 const same = state.antennaChans.filter((c) => c.number === num);324 return same.length === 1 && kind ? same[0].key : k;325 }326 const name = rest.join('|').toLowerCase();327 const named = name ? custom.filter((c) => c.name.toLowerCase() === name) : [];328 if (named.length === 1) return named[0].key;329 const numbered = custom.find((c) => numberKey(c) === numberKey({ number: num }));330 return numbered ? numbered.key : k;331 };332 const favorites = [...new Set((s.favorites || []).map(fix))];333 const hidden = [...new Set((s.hidden || []).map(fix))];334 const wx = weatherChannel();335 let lastChannel = s.lastChannel;336 const numbers = new Set([...state.custom, ...state.antennaChans].map((c) => c.number));337 if (lastChannel === 'WX' && wx) lastChannel = wx.number;338 else if (lastChannel && !numbers.has(lastChannel)) {339 const old = [...(s.favorites || []), ...(s.hidden || [])].find((k) => k.startsWith(`${lastChannel}|custom|`));340 const now = old && state.custom.find((c) => c.key === fix(old));341 if (now) lastChannel = now.number;342 }343 const same = (a, b) => a.length === (b || []).length && a.every((k, i) => k === b[i]);344 if (same(favorites, s.favorites) && same(hidden, s.hidden) && lastChannel === s.lastChannel) return;345 log('info', 'settings: moved saved channels to their current numbers');346 try {347 await saveAppSettings({ ...s, favorites, hidden, lastChannel });348 } catch (e) {349 log('warn', `settings: ${e}`);350 state.settings = { ...s, favorites, hidden, lastChannel };351 }352}353354const isFav = (ch) => !!ch && (state.settings.favorites || []).includes(ch.key);355const isHidden = (ch) => !!ch && (state.settings.hidden || []).includes(ch.key);356357// numberKey is a channel number's value: "1.05" and "1.5" are one number.358function numberKey(c) {359 const [major, minor] = String(c.number).split('.').map((x) => parseInt(x, 10) || 0);360 return `${major}.${minor}`;361}362const byNumber = (a, b) => (a.major - b.major) || (a.minor - b.minor);363364// buildLineup is the channels to show, by number: the server's own and the365// tuner's, less those hidden. A tuner's channel stays when its signal is366// weak now; tuning it then says so. A custom channel takes the place of an367// antenna channel with its number, as it does on the server, which plays it368// for that number.369function buildLineup() {370 const taken = new Set(state.custom.map(numberKey));371 const antenna = state.antennaChans.filter((c) => !taken.has(numberKey(c)) && !isHidden(c));372 state.lineup = [...state.custom.filter((c) => !isHidden(c)), ...antenna].sort(byNumber);373 if (state.current) state.current = state.lineup.find((c) => c.key === state.current.key) || state.current;374}375376const programsFor = (ch) => (ch && ch.weather ? wxPrograms() : (ch && ch.guideId && state.guide && state.guide.programs[ch.guideId]) || []);377const airingAt = (ch, t) => programsFor(ch).find((p) => p._s <= t && t < p._e);378const nextAfter = (ch, t) => programsFor(ch).find((p) => p._s > t);379// stationFor is the licensed transmitter a channel comes from, on its RF380// channel.381const stationFor = (ch) => ch && (state.bySite.get(`${ch.facilityId}:${ch.rf}`) || state.byFacility.get(ch.facilityId));382// sigFor is what was measured of a channel's RF channel.383const sigFor = (ch) => (ch && !ch.own ? { signal: ch.signal, recent: ch.recent } : null);384// nextGen reports whether the tuners take in ATSC 3.0; without, NextGen385// viewing (its guide filter) stays out of sight.386const nextGen = () => !!(state.info && state.info.tuner && state.info.tuner.atsc3);387// displayCall is a channel's short label: a station's call sign, or an own388// channel's call sign, "Airwaves" when it has none.389const displayCall = (ch) => (ch && ch.own ? ch.call || 'Airwaves' : (ch && (ch.baseCall || ch.callSign)) || '');390391function findChannel(number) {392 if (!number) return null;393 const n = number;394 return state.lineup.find((c) => c.number === n)395 || state.lineup.find((c) => c.major === Number(n) && (c.minor === 1 || c.minor === 0))396 || state.antennaChans.find((c) => c.number === n)397 || state.custom.find((c) => c.number === n);398}399400// ---------- boot ----------401function bootLog(msg, err) {402 const li = document.createElement('li');403 li.textContent = msg;404 if (err) li.className = 'err';405 const ol = $('.boot-log');406 ol.append(li);407 while (ol.children.length > 7) ol.firstChild.remove();408}409410async function init() {411 wireKeys();412 wireOSD();413 wireDock();414 wireMouse();415 wireGuide();416 wireAntenna();417 wireSettings();418 wireRecordings();419 wirePodcasts();420 wireWeather();421 wirePads();422 wireTouch();423 wireMediaSession();424 renderHints();425 // Keys work from launch, without a click first.426 window.focus();427 if (document.activeElement && document.activeElement !== document.body && !document.activeElement.matches(TEXT_INPUT)) document.activeElement.blur();428 if (!api()) {429 bootLog('Wails runtime not found. Run this page inside the Airwaves app.', true);430 return;431 }432 window.runtime.EventsOn('progress', (msg) => {433 if (document.body.classList.contains('booting')) bootLog(msg);434 });435 applyBoot(await api().Boot());436 if (!state.settings.server) return showConnect('');437 f($('#boot'), 'sub').textContent = `Connecting to ${state.settings.server}`;438 try {439 if (state.boot.error) throw new Error(state.boot.error);440 await scan(false);441 } catch (e) {442 return showConnect(String(e && e.message ? e.message : e));443 }444 f($('#boot'), 'sub').textContent = state.info.name;445 for (const w of state.report.warnings || []) bootLog(w, true);446 bootLog(`Ready: ${state.lineup.length} channels`);447 setTimeout(() => document.body.classList.remove('booting'), 700);448 // Gone once faded out, so its title stops animating.449 setTimeout(() => { $('#boot').hidden = true; }, 700 + 1200);450451 renderAntenna();452 renderSettings();453 const view = state.boot.view;454 const start = findChannel(state.settings.lastChannel) || state.lineup.find((c) => c.major >= 2) || state.lineup[0];455 if (state.info.playback) toast(`Playback unavailable: ${state.info.playback}`, 8000);456 if (start) tune(start, { quiet: !!view && view !== 'tv' });457 if (view === 'info') {458 // Pinned banner, for screenshots.459 showBanner();460 clearTimeout(state.bannerTimer);461 } else if (view) setView(view);462 setInterval(tick, 30_000);463 setInterval(() => scan(false).then(renderAll).catch((e) => log('warn', e)), 30 * MIN);464 let dvrAt = Date.now();465 setInterval(() => {466 if (state.view === 'guide' || state.view === 'recordings' || Date.now() - dvrAt > 5 * MIN - 1000) {467 dvrAt = Date.now();468 loadDVR();469 }470 }, MIN);471 setInterval(heartbeat, 30_000);472 setInterval(loadWeather, 2 * MIN);473 setInterval(signalTick, 2000);474 loadDVR();475 loadWeather();476 loadPodcasts();477 setInterval(loadPodcasts, 15 * MIN);478}479480document.addEventListener('DOMContentLoaded', init);481482// showConnect asks for the server address on first launch or when the483// configured server cannot be reached.484function showConnect(err) {485 const form = $('.boot-connect');486 f($('#boot'), 'sub').textContent = 'Connect to your Airwaves server';487 if (err) bootLog(err, true);488 form.elements.server.value = state.settings.server || '';489 form.elements.token.value = state.settings.token || '';490 form.hidden = false;491 // In a browser the server is the page's own (web.js): only a token to give.492 const fixed = !!(state.boot && state.boot.fixedServer);493 form.elements.server.readOnly = fixed;494 form.elements.server.parentElement.classList.toggle('fx', !fixed);495 bootField(form.elements[fixed ? 'token' : 'server'].parentElement);496 f($('#boot'), 'hint').innerHTML = hints([['Arrows', 'Arrows', 'move'], ['Enter', 'Enter', androidTV ? 'types, or connects' : 'connects']]);497 form.onsubmit = async (e) => {498 e.preventDefault();499 const server = form.elements.server.value.trim();500 if (!server) return;501 bootLog(`Connecting to ${server}`);502 const b = await api().SaveSettings({ ...state.settings, server, token: form.elements.token.value });503 if (b.error) return bootLog(b.error, true);504 location.reload();505 };506}507508function applyBoot(b) {509 state.boot = b;510 state.settings = b.settings;511 state.info = b.info || { name: '', tuner: {}, dvr: false };512 state.config = b.config || {};513 document.body.classList.toggle('has-dvr', !!state.info.dvr);514 document.body.classList.toggle('has-podcasts', !!state.info.podcasts);515 if (b.lite === '1' || b.lite === '0') setLite(b.lite === '1');516 applyPlatform(b.platform || (androidTV ? 'tv' : 'desktop'));517 applyScale();518 renderHints();519}520521// ---------- recordings ----------522async function loadDVR() {523 if (!state.info || !state.info.dvr) return;524 try {525 state.dvr = await api().DVR();526 } catch (e) {527 log('warn', `dvr: ${e}`);528 return;529 }530 state.dvrKeys = new Map((state.dvr.upcoming || []).map((it) => [it.key, it]));531 if (state.view === 'guide') renderGuide();532 if (state.view === 'recordings') {533 renderRecordings();534 seeFailed();535 }536 renderFailed();537}538539// ---------- failed recordings ----------540// A recording that failed puts a count on Recordings in the dock, and says541// so once, until Recordings is opened.542const failedSeenKey = 'airwaves.failedSeen';543const failedTold = new Set();544const failedRecordings = () => ((state.dvr && state.dvr.recorded) || []).filter((it) => it.status === 'failed');545546function renderFailed() {547 const b = $('#dock [data-view="recordings"]');548 const seen = new Set(JSON.parse(localStorage.getItem(failedSeenKey) || '[]'));549 const unseen = failedRecordings().filter((it) => !seen.has(it.id));550 let badge = $('.badge', b);551 if (!unseen.length) {552 if (badge) badge.remove();553 return;554 }555 if (!badge) {556 badge = document.createElement('span');557 badge.className = 'badge';558 b.append(badge);559 }560 badge.textContent = String(unseen.length);561 badge.title = `${unseen.length} recording${unseen.length > 1 ? 's' : ''} failed`;562 const news = unseen.filter((it) => !failedTold.has(it.id));563 for (const it of news) failedTold.add(it.id);564 if (news.length === 1) toast(`Recording failed: ${news[0].title}${news[0].detail ? `. ${news[0].detail}` : ''}`, 7000);565 else if (news.length) toast(`${news.length} recordings failed. Recordings has them.`, 7000);566}567568function seeFailed() {569 localStorage.setItem(failedSeenKey, JSON.stringify(failedRecordings().map((it) => it.id)));570 renderFailed();571}572573const airingKey = (ch, p) => `${ch.number}@${Math.floor(p._s / 1000)}`;574575function seriesRuleFor(ch, p) {576 return p && p.seriesId && state.dvr && (state.dvr.rules || []).find((r) => r.kind === 'series' && r.seriesId === p.seriesId && r.channel === ch.number);577}578579function renderAll() {580 renderBanner();581 if (state.view === 'guide') renderGuide();582 renderAntenna();583 renderSettings();584}585586function tick() {587 renderBanner();588 if (state.view === 'guide') renderGuide();589 applyCaptions(); // a new program may want them otherwise590}591592// ---------- views ----------593function setView(v) {594 if (state.dock >= 0) closeDock(false);595 if (v === state.view) return restoreFocus();596 const prev = state.view;597 // The banner and the controls go with TV (their timers with them).598 if (prev === 'tv') hideBanner();599 // The focus starts over in the new view.600 blurIn(document.body);601 state.view = v;602 document.body.classList.remove(`mode-${prev}`);603 document.body.classList.add(`mode-${v}`);604 $$('#dock button').forEach((b) => b.classList.toggle('on', b.dataset.view === v));605 if (v === 'guide') { openGuide(); loadDVR(); }606 if (v === 'recordings') { openRecordings(); loadDVR(); }607 if (v === 'weather') {608 renderWeather();609 wxUI.at = 0;610 wxFocus();611 loadWeather();612 if (state.info.weatherStar && !(state.current && state.current.weather)) {613 state.wxPreview = true;614 video.muted = true;615 showWX(true, true);616 }617 }618 if (prev === 'weather' && state.wxPreview) {619 state.wxPreview = false;620 video.muted = !!state.userMuted;621 if (!(state.current && state.current.weather)) showWX(false);622 }623 if (v === 'settings') openSettings();624 if (v === 'podcasts') openPodcasts();625 if (v === 'tv' && state.staleStream) restartStream();626 if (v === 'recordings') seeFailed();627 return undefined;628}629630function wireDock() {631 $$('#dock button').forEach((b) => b.addEventListener('click', () => setView(b.dataset.view)));632 $('#dock button[data-view="tv"]').classList.add('on');633 stage.addEventListener('click', () => {634 if (state.view === 'weather' && state.wxPreview) tune(weatherChannel());635 if (state.view !== 'tv') setView('tv');636 });637 stage.addEventListener('dblclick', () => { if (state.view === 'tv') toggleFullscreen(); });638}639640let mouseTimer = 0;641function wireMouse() {642 // WebKitGTK focuses a button when it is clicked, and Enter or Space then643 // pressed it again besides doing what the key does here. Clicks leave644 // the focus where it was, as on a Mac.645 document.addEventListener('mousedown', (e) => {646 if (!e.target.closest(TEXT_INPUT) && e.target.closest('button, .fx')) e.preventDefault();647 });648 // Only a pointer that really moved counts. WebKit also sends moves (and649 // mouseenter) when content appears under a pointer left still, as on a650 // TV where it rests wherever it was; the banner then never hid.651 document.addEventListener('mousemove', (e) => {652 if (!e.movementX && !e.movementY) return;653 document.body.classList.add('mouse-active');654 clearTimeout(mouseTimer);655 mouseTimer = setTimeout(() => document.body.classList.remove('mouse-active'), 2500);656 // The controls, for a pointer near the bottom; no focus mark.657 if (state.view === 'tv' && e.clientY > window.innerHeight * 0.55) openOSD(null);658 });659}660661async function toggleFullscreen() {662 const rt = window.runtime;663 if (!rt) return;664 if (await rt.WindowIsFullscreen()) rt.WindowUnfullscreen(); else rt.WindowFullscreen();665}