clone

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

Split the app by area into js/, in load order

FelineStateMachine committed a440a82
app.js had grown to 5,000 lines. It's now ten files by area (core,
player, input, guide, reception, settings, recordings, captions,
playback, weather), loaded in their old order as plain scripts sharing
one scope: still no build step, and nothing changes for the TV, which
loads them from /tv/ as before. The embed test checks the page loads
every file there.
commit
a440a82686881f280b7cc3bab902e6070bdf26e8
parent
dc8aac0ecb5a3da68342ad58ff1a00c869ed575f
author
FelineStateMachine <226808055+FelineStateMachine@users.noreply.github.com>
kind
refactor risk score 1.6 of 3, Jev's estimate
18 files changed, +5281 -5223
MREADME.md3+-
Dfrontend/dist/app.js5189------------------------------
Mfrontend/dist/index.html21+-
Afrontend/dist/js/captions.js602+++
Afrontend/dist/js/core.js655++++
Afrontend/dist/js/guide.js542+++
Afrontend/dist/js/input.js579+++
Afrontend/dist/js/playback.js361++
Afrontend/dist/js/player.js648++++
Afrontend/dist/js/reception.js493+++
Afrontend/dist/js/recordings.js375++
Afrontend/dist/js/settings.js669++++
Afrontend/dist/js/weather.js294++
Mfrontend/dist/style.css24+-
Mfrontend/dist/web.js6+-
Mfrontend/embed_test.go31+-
Minternal/api/tv_test.go10+-
Mscripts/tunebench.mjs2+-
M README.md +2 -1
D frontend/dist/app.js +0 -5189
@@ -1,5189 +0,0 @@
1-'use strict';2-3-// ---------- helpers ----------4-const $ = (s, r = document) => r.querySelector(s);5-const $$ = (s, r = document) => [...r.querySelectorAll(s)];6-const f = (root, name) => root.querySelector(`[data-f="${name}"]`);7-const api = () => window.go && window.go.main && window.go.main.App;8-const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));9-const MIN = 60_000;10-const floor30 = (t) => Math.floor(t / (30 * MIN)) * 30 * MIN;11-// One formatter for every time shown: toLocaleTimeString makes a new one12-// each call, which is most of the guide's build time.13-const clockFormat = new Intl.DateTimeFormat([], { hour: 'numeric', minute: '2-digit' });14-const clock = (t) => clockFormat.format(new Date(t));15-const COMPASS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];16-const compass = (deg) => COMPASS[Math.round(deg / 22.5) % 16];17-18-function log(level, msg) {19- try { api().Log(level, String(msg)); } catch { /* not in Wails */ }20-}21-22-// ---------- signal ----------23-// Everything shown of reception was measured by the server's tuners (its24-// /api/signal): per RF channel, the latest reading and the latest stretch25-// of readings (recent: the last Measure now, or the last while something26-// watched it), with lows and means. The stretch is what's shown: a single27-// reading of a marginal signal jumps around. The HDHomeRun reports28-// strength, quality (its signal-to-noise quality) and symbol quality (the29-// share of data arriving whole or corrected) as percentages; the errors in30-// what arrived the server counts itself.31-const SIG_COLOR = { lock: 'var(--amber)', part: 'var(--amber-2)', off: 'var(--red)', none: 'var(--faint)' };32-33-// sigOf sums up a measurement ({ signal, recent }, of a channel or an RF34-// channel): its state ('lock' throughout, 'part' some of the time, 'off'35-// none, 'none' never measured), bars to show (0 to 5, from the measured36-// quality), a label and the details.37-function sigOf(m) {38- const r = m && m.recent;39- const sig = m && m.signal;40- 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 };41- const lockedPct = r ? r.lockedPct : sig.lock ? 100 : 0;42- const state = lockedPct >= 100 ? 'lock' : lockedPct > 0 ? 'part' : 'off';43- // From the stretch, its means; else the one reading.44- const val = (key) => (r ? (r[key] ? r[key].avg : null) : sig[key]);45- const src = r || sig;46- const q = val('qualityPct');47- const strengthPct = val('strengthPct');48- const symbolPct = val('symbolPct');49- let bars = 0;50- if (state !== 'off') bars = q == null ? 3 : q >= 70 ? 5 : q >= 55 ? 4 : q >= 40 ? 3 : q >= 25 ? 2 : q > 0 ? 1 : 0;51- const quality = q != null ? `${Math.round(q)}%` : '';52- const strength = strengthPct != null ? `${Math.round(strengthPct)}%` : '';53- const symbol = symbolPct != null ? `${Math.round(symbolPct)}%` : '';54- const errors = sig && sig.errorsPerSec != null ? sig.errorsPerSec : null;55- const label = state === 'off' ? 'No lock'56- : state === 'part' ? `Locked ${Math.round(lockedPct)}% of the time`57- : quality ? `Quality ${quality}` : 'Locked';58- return { state, bars, label, color: SIG_COLOR[state], quality, strength, symbol, errors, at: Date.parse(src.to || src.at), source: src.source, lockedPct };59-}60-61-// meter draws a measurement's bars.62-function meter(s, big) {63- let html = '';64- for (let i = 1; i <= 5; i++) html += `<i class="${i <= s.bars ? 'on' : ''}"></i>`;65- return `<span class="meter${big ? ' big' : ''} m-${s.state}" style="--c:${s.color}">${html}</span>`;66-}67-68-// ago says how long ago a time was, roughly.69-function ago(t) {70- const m = Math.round((Date.now() - t) / MIN);71- if (m < 1) return 'just now';72- if (m < 60) return `${m} min ago`;73- const h = Math.round(m / 60);74- if (h < 24) return `${h} h ago`;75- return new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' });76-}77-78-// agoShort is ago for a table: "10 min", "3 h", "Oct 3".79-function agoShort(t) {80- const m = Math.round((Date.now() - t) / MIN);81- if (m < 1) return 'now';82- if (m < 60) return `${m} min`;83- if (m < 24 * 60) return `${Math.round(m / 60)} h`;84- return new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' });85-}86-87-// sigDetail is a measurement's details in words: "strength 82%, 1888-// errors a second, 5 min ago".89-function sigDetail(s) {90- if (s.state === 'none') return 'Measure now in Reception reads it';91- const parts = [];92- if (s.state !== 'lock' && s.quality && s.state !== 'off') parts.push(`quality ${s.quality}`);93- if (s.strength) parts.push(`strength ${s.strength}`);94- if (s.symbol && s.state !== 'off' && s.symbol !== '100%') parts.push(`symbol quality ${s.symbol}`);95- if (s.errors != null && s.state !== 'off') parts.push(s.errors >= 1 ? `${Math.round(s.errors)} errors a second` : 'no errors');96- parts.push(`${ago(s.at)}${s.source === 'active' ? ', while tuned' : ''}`);97- return parts.join(', ');98-}99-100-// trouble is a measurement that explains a bad picture: no lock, a lock101-// that comes and goes, or a stream breaking up (5 errors a second or more).102-const trouble = (s) => s.state === 'off' || s.state === 'part' || (s.errors != null && s.errors >= 5);103-104-let toastTimer = 0;105-function toast(msg, ms = 3500, html = false) {106- const t = $('#toast');107- if (html) t.innerHTML = msg; else t.textContent = msg;108- t.classList.add('show');109- clearTimeout(toastTimer);110- toastTimer = setTimeout(() => t.classList.remove('show'), ms);111-}112-113-// Lite mode is for Linux, where the app is WebKitGTK on a TV box: the same114-// look without the blurs, film grain, big soft shadows and animations that115-// cost a weak CPU and GPU most (style.css), and less video kept in memory.116-// AIRWAVES_LITE=1 or 0 (in Boot) turns it on or off anywhere.117-let lite = false;118-function setLite(on) {119- lite = on;120- document.body.classList.toggle('lite', on);121-}122-setLite(/Linux/.test(navigator.userAgent) && !/Android/.test(navigator.userAgent));123-124-// The Android TV app is a WebView whose user agent ends in125-// AirwavesTV/<version>, showing this page from the server (web.js). This126-// says only what's the WebView's own (its keyboard, the Home key); what a127-// TV is like is the platform's.128-const androidTV = /\bAirwavesTV\//.test(navigator.userAgent);129-130-// ---------- platform ----------131-// Airwaves is three experiences, each whole and kept apart (body.pf-*,132-// and the checks of platform):133-// desktop: the app in a window on a Mac, Linux or Windows, or a browser.134-// A mouse and a keyboard: hints name keys, letters are shortcuts, the135-// dock shows with the pointer.136-// console: the Linux app under gamescope, SteamOS's Game Mode on a TV.137-// A controller: hints show its buttons from the start, sized for138-// across a room.139-// tv: the Android TV app. A remote with arrows, OK, Back and media keys140-// but no letters or pointer: hints name the remote's keys, holding OK141-// offers more, and the on-screen controls lead to everything.142-// The app says which it is (Boot's platform: AIRWAVES_PLATFORM, else a143-// console's under gamescope; web.js: the Android TV app's TV, else a144-// desktop's, or ?platform=), and Settings, Display, Layout can say145-// otherwise on this device. What's pressed still counts: a controller on a146-// desktop brings its buttons to the hints, a keyboard on a TV its keys.147-const PLATFORMS = [['desktop', 'Desktop'], ['console', 'Console'], ['tv', 'TV']];148-const platformKey = 'airwaves.platform';149-let platform = 'desktop';150-let detectedPlatform = 'desktop';151-// onRemote: the hints and the ways to do things are a TV remote's.152-const onRemote = () => platform === 'tv' && !prompts.pad;153-// onKeys: they're a keyboard's, letters and all.154-const onKeys = () => platform !== 'tv' && !prompts.pad;155-156-function applyPlatform(detected) {157- if (detected) detectedPlatform = PLATFORMS.some(([k]) => k === detected) ? detected : 'desktop';158- const forced = localStorage.getItem(platformKey) || '';159- const next = PLATFORMS.some(([k]) => k === forced) ? forced : detectedPlatform;160- for (const [k] of PLATFORMS) document.body.classList.toggle(`pf-${k}`, k === next);161- const changed = next !== platform;162- platform = next;163- // A console starts with its controller's buttons in the hints.164- if (platform === 'console') setPrompts(true);165- else if (changed) setPrompts(false);166- return changed;167-}168-169-// ---------- state ----------170-const state = {171- boot: null,172- settings: null,173- report: null,174- guide: null,175- byFacility: new Map(), // facilityId -> nearest station176- bySite: new Map(), // "facilityId:rf" -> station177- antenna: null, // the tuner and its RF channels, as last measured (/api/signal)178- antennaChans: [], // the tuner's channels, hidden ones too179- byRF: new Map(), // RF channel -> what's known of it (antenna.muxes)180- sigStamp: 0, // counts signal updates, for redraws181- lineup: [],182- custom: [], // the server's own channels: the weather channel, folder, Jellyfin and YouTube channels183- current: null,184- previous: null,185- note: '',186- view: 'tv',187- tuneToken: 0,188- hls: null,189- entry: '',190- entryTimer: 0,191- bannerTimer: 0,192- guideFilter: 'all',193- gStart: 0,194- gSpan: 150,195- gRow: 0,196- gTime: 0,197- aRF: 0, // the RF channel picked in Reception198- info: null, // backend: local engine or a server199- config: null, // location the lineup is built for200- dvr: null, // recording state from the server201- dvrKeys: new Map(), // "7.1@1791058239" -> upcoming item202- recPick: '', // the airing whose Record is open to its choices203- recording: null, // recording being played instead of live TV204- captionsFor: null, // captions turned off or on for one program: { key, on }205- recTab: 'library',206- recSelBy: null,207- dock: -1, // dock button picked with the arrow keys, or -1208-};209-210-const video = $('#video');211-const stage = $('#stage');212-213-// ---------- data ----------214-async function scan(refresh) {215- const snap = await api().Scan(refresh);216- state.report = snap.report;217- state.guide = snap.guide || { programs: {} };218- // The antenna channels are the tuner's: those its scan found, described219- // from the FCC's records and the listings. Their keys (for favorites and220- // hidden channels) carry the listings' call sign where there is one221- // ("2.1|KWGNDT"), as keys always have, else the station's.222- state.antennaChans = (snap.antenna ? snap.antenna.channels : snap.report.channels || [])223- .map((c) => ({ ...c, key: `${c.number}|${c.guideCallSign || c.callSign}` }));224- if (snap.antenna) applySignal(snap.antenna);225- // The server's own channels, with the details set on its admin page. The226- // weather channel plays here, from the WeatherStar display; the others'227- // schedules join the guide like a station's, under their own guide id,228- // their category counting as a genre of each program. A server from229- // before the weather channel was listed here sends no list, or channels230- // without a kind: then the weather channel is the old WX.231- const listed = Array.isArray(snap.custom) && snap.custom.every((c) => c.kind);232- state.custom = (snap.custom || []).map((c) => {233- const ch = ownChannel(c);234- if (!ch.weather) {235- const genres = categoryGenres(ch.category);236- state.guide.programs[ch.guideId] = (c.programs || []).map((p) => (genres.length ? { ...p, genres: [...new Set([...(p.genres || []), ...genres])] } : p));237- }238- return ch;239- });240- if (!listed && state.info.weatherStar && !state.custom.some((c) => c.weather)) state.custom.unshift(OLD_WX);241- indexData();242- await migrateKeys();243- buildLineup();244-}245-246-function indexData() {247- state.byFacility.clear();248- state.bySite.clear();249- const stations = [...state.report.stations].sort((a, b) => a.distanceKm - b.distanceKm);250- for (const s of stations) {251- if (!state.byFacility.has(s.facilityId)) state.byFacility.set(s.facilityId, s);252- state.bySite.set(`${s.facilityId}:${s.rfChannel}`, s);253- }254- const progs = (state.guide && state.guide.programs) || {};255- for (const list of Object.values(progs)) {256- for (const p of list) { p._s = Date.parse(p.start); p._e = Date.parse(p.end); }257- }258-}259-260-// ---------- the server's own channels ----------261-// Keys (for favorites and hidden channels) stay with a channel through a new262-// number or name: the weather channel has one key, and a custom channel's263-// carries its name as well as its number (migrateKeys follows either).264-const WEATHER_KEY = 'weather';265-266-// A custom channel's category counts as these genres for its programs, so267-// the guide's filters find it; categories with no filter of their own get268-// one while a channel has them (guideFilters).269-const CATEGORY_GENRES = {270- Sports: ['sports'], Movies: ['movie'], News: ['news'], Family: ['family'], Kids: ['kids', 'family'],271- Music: ['music'], Pets: ['pets'], Gaming: ['gaming'], Documentary: ['documentary'], Weather: ['weather'],272-};273-const categoryGenres = (category) => CATEGORY_GENRES[category] || [];274-275-// ownChannel is a channel the server lists as its own, for the lineup. Its276-// call sign is the short label (call); its name stands where a station's277-// network does.278-function ownChannel(c) {279- const [major, minor] = c.number.split('.').map(Number);280- const weather = c.kind === 'weather';281- return {282- key: weather ? WEATHER_KEY : `${c.number}|custom|${c.name}`,283- number: c.number, major: major || 0, minor: minor || 0, name: c.name, network: c.name,284- call: c.callSign || '', callSign: c.callSign || '', baseCall: c.callSign || '',285- kind: c.kind || 'folder', category: c.category || 'Other', description: c.description || '',286- logo: c.logo ? state.boot.serverUrl + c.logo : '',287- own: true, weather, custom: !weather, guideId: weather ? '' : `custom:${c.number}`,288- };289-}290-291-// OLD_WX is the weather channel of a server from before it listed it.292-const OLD_WX = {293- key: WEATHER_KEY, number: 'WX', major: 0, minor: 0, name: 'Airwaves Weather', network: 'Local Forecast',294- call: 'WX', callSign: 'WX', baseCall: 'WX', kind: 'weather', category: 'Weather', description: '', logo: '',295- own: true, weather: true, custom: false, guideId: '',296-};297-298-const weatherChannel = () => state.custom.find((c) => c.weather) || null;299-300-// migrateKeys moves saved favorites and hidden channels to where the301-// server's own channels are now: the weather channel's from the old WX key,302-// and a custom channel's to its current number and name (found by name, or303-// else by number). An antenna channel's whose call sign changed moves to304-// the one channel with its number. Keys of channels not listed now are kept305-// for when they are. A saved last channel of WX becomes the weather306-// channel's number, and one no longer in use the new number of the custom307-// channel a saved key names with it.308-async function migrateKeys() {309- const s = state.settings;310- const custom = state.custom.filter((c) => !c.weather);311- const known = new Set([...state.custom, ...state.antennaChans].map((c) => c.key));312- const fix = (k) => {313- if (k === 'WX|WX') return WEATHER_KEY;314- if (known.has(k)) return k;315- const [num, kind, ...rest] = String(k).split('|');316- if (kind !== 'custom') {317- const same = state.antennaChans.filter((c) => c.number === num);318- return same.length === 1 && kind ? same[0].key : k;319- }320- const name = rest.join('|').toLowerCase();321- const named = name ? custom.filter((c) => c.name.toLowerCase() === name) : [];322- if (named.length === 1) return named[0].key;323- const numbered = custom.find((c) => numberKey(c) === numberKey({ number: num }));324- return numbered ? numbered.key : k;325- };326- const favorites = [...new Set((s.favorites || []).map(fix))];327- const hidden = [...new Set((s.hidden || []).map(fix))];328- const wx = weatherChannel();329- let lastChannel = s.lastChannel;330- const numbers = new Set([...state.custom, ...state.antennaChans].map((c) => c.number));331- if (lastChannel === 'WX' && wx) lastChannel = wx.number;332- else if (lastChannel && !numbers.has(lastChannel)) {333- const old = [...(s.favorites || []), ...(s.hidden || [])].find((k) => k.startsWith(`${lastChannel}|custom|`));334- const now = old && state.custom.find((c) => c.key === fix(old));335- if (now) lastChannel = now.number;336- }337- const same = (a, b) => a.length === (b || []).length && a.every((k, i) => k === b[i]);338- if (same(favorites, s.favorites) && same(hidden, s.hidden) && lastChannel === s.lastChannel) return;339- log('info', 'settings: moved saved channels to their current numbers');340- try {341- await saveAppSettings({ ...s, favorites, hidden, lastChannel });342- } catch (e) {343- log('warn', `settings: ${e}`);344- state.settings = { ...s, favorites, hidden, lastChannel };345- }346-}347-348-const isFav = (ch) => !!ch && (state.settings.favorites || []).includes(ch.key);349-const isHidden = (ch) => !!ch && (state.settings.hidden || []).includes(ch.key);350-351-// numberKey is a channel number's value: "1.05" and "1.5" are one number.352-function numberKey(c) {353- const [major, minor] = String(c.number).split('.').map((x) => parseInt(x, 10) || 0);354- return `${major}.${minor}`;355-}356-const byNumber = (a, b) => (a.major - b.major) || (a.minor - b.minor);357-358-// buildLineup is the channels to show, by number: the server's own and the359-// tuner's, less those hidden. A tuner's channel stays when its signal is360-// weak now; tuning it then says so. A custom channel takes the place of an361-// antenna channel with its number, as it does on the server, which plays it362-// for that number.363-function buildLineup() {364- const taken = new Set(state.custom.map(numberKey));365- const antenna = state.antennaChans.filter((c) => !taken.has(numberKey(c)) && !isHidden(c));366- state.lineup = [...state.custom.filter((c) => !isHidden(c)), ...antenna].sort(byNumber);367- if (state.current) state.current = state.lineup.find((c) => c.key === state.current.key) || state.current;368-}369-370-const programsFor = (ch) => (ch && ch.weather ? wxPrograms() : (ch && ch.guideId && state.guide && state.guide.programs[ch.guideId]) || []);371-const airingAt = (ch, t) => programsFor(ch).find((p) => p._s <= t && t < p._e);372-const nextAfter = (ch, t) => programsFor(ch).find((p) => p._s > t);373-// stationFor is the licensed transmitter a channel comes from, on its RF374-// channel.375-const stationFor = (ch) => ch && (state.bySite.get(`${ch.facilityId}:${ch.rf}`) || state.byFacility.get(ch.facilityId));376-// sigFor is what was measured of a channel's RF channel.377-const sigFor = (ch) => (ch && !ch.own ? { signal: ch.signal, recent: ch.recent } : null);378-// nextGen reports whether the tuners take in ATSC 3.0; without, NextGen379-// viewing (its guide filter) stays out of sight.380-const nextGen = () => !!(state.info && state.info.tuner && state.info.tuner.atsc3);381-// displayCall is a channel's short label: a station's call sign, or an own382-// channel's call sign, "Airwaves" when it has none.383-const displayCall = (ch) => (ch && ch.own ? ch.call || 'Airwaves' : (ch && (ch.baseCall || ch.callSign)) || '');384-385-function findChannel(number) {386- if (!number) return null;387- const n = number;388- return state.lineup.find((c) => c.number === n)389- || state.lineup.find((c) => c.major === Number(n) && (c.minor === 1 || c.minor === 0))390- || state.antennaChans.find((c) => c.number === n)391- || state.custom.find((c) => c.number === n);392-}393-394-// ---------- boot ----------395-function bootLog(msg, err) {396- const li = document.createElement('li');397- li.textContent = msg;398- if (err) li.className = 'err';399- const ol = $('.boot-log');400- ol.append(li);401- while (ol.children.length > 7) ol.firstChild.remove();402-}403-404-async function init() {405- wireKeys();406- wireOSD();407- wireDock();408- wireMouse();409- wireGuide();410- wireAntenna();411- wireSettings();412- wireRecordings();413- wireWeather();414- wirePads();415- wireMediaSession();416- renderHints();417- // Keys work from launch, without a click first.418- window.focus();419- if (document.activeElement && document.activeElement !== document.body && !document.activeElement.matches(TEXT_INPUT)) document.activeElement.blur();420- if (!api()) {421- bootLog('Wails runtime not found. Run this page inside the Airwaves app.', true);422- return;423- }424- window.runtime.EventsOn('progress', (msg) => {425- if (document.body.classList.contains('booting')) bootLog(msg);426- });427- applyBoot(await api().Boot());428- if (!state.settings.server) return showConnect('');429- f($('#boot'), 'sub').textContent = `Connecting to ${state.settings.server}`;430- try {431- if (state.boot.error) throw new Error(state.boot.error);432- await scan(false);433- } catch (e) {434- return showConnect(String(e && e.message ? e.message : e));435- }436- f($('#boot'), 'sub').textContent = state.info.name;437- for (const w of state.report.warnings || []) bootLog(w, true);438- bootLog(`Ready: ${state.lineup.length} channels`);439- setTimeout(() => document.body.classList.remove('booting'), 700);440- // Gone once faded out, so its title stops animating.441- setTimeout(() => { $('#boot').hidden = true; }, 700 + 1200);442-443- renderAntenna();444- renderSettings();445- const view = state.boot.view;446- const start = findChannel(state.settings.lastChannel) || state.lineup.find((c) => c.major >= 2) || state.lineup[0];447- if (state.info.playback) toast(`Playback unavailable: ${state.info.playback}`, 8000);448- if (start) tune(start, { quiet: !!view && view !== 'tv' });449- if (view === 'info') {450- // Pinned banner, for screenshots.451- showBanner();452- clearTimeout(state.bannerTimer);453- } else if (view) setView(view);454- setInterval(tick, 30_000);455- setInterval(() => scan(false).then(renderAll).catch((e) => log('warn', e)), 30 * MIN);456- let dvrAt = Date.now();457- setInterval(() => {458- if (state.view === 'guide' || state.view === 'recordings' || Date.now() - dvrAt > 5 * MIN - 1000) {459- dvrAt = Date.now();460- loadDVR();461- }462- }, MIN);463- setInterval(heartbeat, 30_000);464- setInterval(loadWeather, 2 * MIN);465- setInterval(signalTick, 2000);466- loadDVR();467- loadWeather();468-}469-470-// showConnect asks for the server address on first launch or when the471-// configured server cannot be reached.472-function showConnect(err) {473- const form = $('.boot-connect');474- f($('#boot'), 'sub').textContent = 'Connect to your Airwaves server';475- if (err) bootLog(err, true);476- form.elements.server.value = state.settings.server || '';477- form.elements.token.value = state.settings.token || '';478- form.hidden = false;479- // In a browser the server is the page's own (web.js): only a token to give.480- const fixed = !!(state.boot && state.boot.fixedServer);481- form.elements.server.readOnly = fixed;482- form.elements.server.parentElement.classList.toggle('fx', !fixed);483- bootField(form.elements[fixed ? 'token' : 'server'].parentElement);484- f($('#boot'), 'hint').innerHTML = hints([['Arrows', 'Arrows', 'move'], ['Enter', 'Enter', androidTV ? 'types, or connects' : 'connects']]);485- form.onsubmit = async (e) => {486- e.preventDefault();487- const server = form.elements.server.value.trim();488- if (!server) return;489- bootLog(`Connecting to ${server}`);490- const b = await api().SaveSettings({ ...state.settings, server, token: form.elements.token.value });491- if (b.error) return bootLog(b.error, true);492- location.reload();493- };494-}495-496-function applyBoot(b) {497- state.boot = b;498- state.settings = b.settings;499- state.info = b.info || { name: '', tuner: {}, dvr: false };500- state.config = b.config || {};501- document.body.classList.toggle('has-dvr', !!state.info.dvr);502- if (b.lite === '1' || b.lite === '0') setLite(b.lite === '1');503- applyPlatform(b.platform || (androidTV ? 'tv' : 'desktop'));504- applyScale();505- renderHints();506-}507-508-// ---------- recordings ----------509-async function loadDVR() {510- if (!state.info || !state.info.dvr) return;511- try {512- state.dvr = await api().DVR();513- } catch (e) {514- log('warn', `dvr: ${e}`);515- return;516- }517- state.dvrKeys = new Map((state.dvr.upcoming || []).map((it) => [it.key, it]));518- if (state.view === 'guide') renderGuide();519- if (state.view === 'recordings') {520- renderRecordings();521- seeFailed();522- }523- renderFailed();524-}525-526-// ---------- failed recordings ----------527-// A recording that failed puts a count on Recordings in the dock, and says528-// so once, until Recordings is opened.529-const failedSeenKey = 'airwaves.failedSeen';530-const failedTold = new Set();531-const failedRecordings = () => ((state.dvr && state.dvr.recorded) || []).filter((it) => it.status === 'failed');532-533-function renderFailed() {534- const b = $('#dock [data-view="recordings"]');535- const seen = new Set(JSON.parse(localStorage.getItem(failedSeenKey) || '[]'));536- const unseen = failedRecordings().filter((it) => !seen.has(it.id));537- let badge = $('.badge', b);538- if (!unseen.length) {539- if (badge) badge.remove();540- return;541- }542- if (!badge) {543- badge = document.createElement('span');544- badge.className = 'badge';545- b.append(badge);546- }547- badge.textContent = String(unseen.length);548- badge.title = `${unseen.length} recording${unseen.length > 1 ? 's' : ''} failed`;549- const news = unseen.filter((it) => !failedTold.has(it.id));550- for (const it of news) failedTold.add(it.id);551- if (news.length === 1) toast(`Recording failed: ${news[0].title}${news[0].detail ? `. ${news[0].detail}` : ''}`, 7000);552- else if (news.length) toast(`${news.length} recordings failed. Recordings has them.`, 7000);553-}554-555-function seeFailed() {556- localStorage.setItem(failedSeenKey, JSON.stringify(failedRecordings().map((it) => it.id)));557- renderFailed();558-}559-560-const airingKey = (ch, p) => `${ch.number}@${Math.floor(p._s / 1000)}`;561-562-function seriesRuleFor(ch, p) {563- return p && p.seriesId && state.dvr && (state.dvr.rules || []).find((r) => r.kind === 'series' && r.seriesId === p.seriesId && r.channel === ch.number);564-}565-566-function renderAll() {567- renderBanner();568- if (state.view === 'guide') renderGuide();569- renderAntenna();570- renderSettings();571-}572-573-function tick() {574- renderBanner();575- if (state.view === 'guide') renderGuide();576- applyCaptions(); // a new program may want them otherwise577-}578-579-// ---------- views ----------580-function setView(v) {581- if (state.dock >= 0) closeDock(false);582- if (v === state.view) return restoreFocus();583- const prev = state.view;584- // The banner and the controls go with TV (their timers with them).585- if (prev === 'tv') hideBanner();586- // The focus starts over in the new view.587- blurIn(document.body);588- state.view = v;589- document.body.classList.remove(`mode-${prev}`);590- document.body.classList.add(`mode-${v}`);591- $$('#dock button').forEach((b) => b.classList.toggle('on', b.dataset.view === v));592- if (v === 'guide') { openGuide(); loadDVR(); }593- if (v === 'recordings') { openRecordings(); loadDVR(); }594- if (v === 'weather') {595- renderWeather();596- wxUI.at = 0;597- wxFocus();598- loadWeather();599- if (state.info.weatherStar && !(state.current && state.current.weather)) {600- state.wxPreview = true;601- video.muted = true;602- showWX(true, true);603- }604- }605- if (prev === 'weather' && state.wxPreview) {606- state.wxPreview = false;607- video.muted = !!state.userMuted;608- if (!(state.current && state.current.weather)) showWX(false);609- }610- if (v === 'settings') openSettings();611- if (v === 'tv' && state.staleStream) restartStream();612- if (v === 'recordings') seeFailed();613- return undefined;614-}615-616-function wireDock() {617- $$('#dock button').forEach((b) => b.addEventListener('click', () => setView(b.dataset.view)));618- $('#dock button[data-view="tv"]').classList.add('on');619- stage.addEventListener('click', () => {620- if (state.view === 'weather' && state.wxPreview) tune(weatherChannel());621- if (state.view !== 'tv') setView('tv');622- });623- stage.addEventListener('dblclick', () => { if (state.view === 'tv') toggleFullscreen(); });624-}625-626-let mouseTimer = 0;627-function wireMouse() {628- // WebKitGTK focuses a button when it is clicked, and Enter or Space then629- // pressed it again besides doing what the key does here. Clicks leave630- // the focus where it was, as on a Mac.631- document.addEventListener('mousedown', (e) => {632- if (!e.target.closest(TEXT_INPUT) && e.target.closest('button, .fx')) e.preventDefault();633- });634- // Only a pointer that really moved counts. WebKit also sends moves (and635- // mouseenter) when content appears under a pointer left still, as on a636- // TV where it rests wherever it was; the banner then never hid.637- document.addEventListener('mousemove', (e) => {638- if (!e.movementX && !e.movementY) return;639- document.body.classList.add('mouse-active');640- clearTimeout(mouseTimer);641- mouseTimer = setTimeout(() => document.body.classList.remove('mouse-active'), 2500);642- // The controls, for a pointer near the bottom; no focus mark.643- if (state.view === 'tv' && e.clientY > window.innerHeight * 0.55) openOSD(null);644- });645-}646-647-async function toggleFullscreen() {648- const rt = window.runtime;649- if (!rt) return;650- if (await rt.WindowIsFullscreen()) rt.WindowUnfullscreen(); else rt.WindowFullscreen();651-}652-653-// ---------- tuning & playback ----------654-// at is when the viewer asked for the channel (a key press), for timing655-// the change.656-async function tune(ch, { quiet = false, at = performance.now() } = {}) {657- if (!ch) return;658- if (state.recording) saveRecordingProgress();659- if (state.current && state.current.key !== ch.key) state.previous = state.current;660- state.current = ch;661- state.note = '';662- state.recording = null;663- state.staleStream = false;664- const token = ++state.tuneToken;665- if (!quiet) showBanner(); else renderBanner();666- stage.classList.add('tuning');667- $('#nosignal').hidden = true;668- showWX(!!ch.weather);669- if (ch.weather) {670- resetPlayer();671- api().StopTV();672- setTimeout(() => stage.classList.remove('tuning'), 400);673- return renderBanner();674- }675- // The server ends this screen's stream as it starts the next: the676- // picture plays out what it has, without asking for more of it.677- if (state.hls) state.hls.stopLoad();678- let played = false;679- try {680- const pb = await api().Tune(ch.number);681- if (token !== state.tuneToken) return;682- state.note = pb.note || '';683- await play(pb, token);684- played = token === state.tuneToken;685- if (played) reportTune(ch, performance.now() - at);686- } catch (e) {687- if (token !== state.tuneToken) return;688- log('warn', `tune ${ch.number}: ${e}`);689- showNoSignal(ch, String(e && e.message ? e.message : e));690- // The server read the tuner while it tried.691- if (!ch.own) loadSignal();692- } finally {693- // The static clears as the first frame shows.694- if (played) onFirstFrame(() => { if (token === state.tuneToken) stage.classList.remove('tuning'); });695- else if (token === state.tuneToken) setTimeout(() => stage.classList.remove('tuning'), 200);696- }697- renderBanner();698-}699-700-// onFirstFrame calls fn once the video's first frame is on screen, where701-// the browser can tell (requestVideoFrameCallback), else a moment after702-// playback starts; within a second either way.703-function onFirstFrame(fn) {704- let done = false;705- const once = () => { if (!done) { done = true; fn(); } };706- if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(once);707- setTimeout(once, video.requestVideoFrameCallback ? 1000 : 200);708-}709-710-// reportTune tells the server how long a channel change took, from the711-// key to the picture playing, for the times on its admin page.712-function reportTune(ch, ms) {713- if (!api().ReportTune) return;714- api().ReportTune({ number: ch.number, kind: ch.own ? ch.kind : 'antenna', firstFrameMs: Math.round(ms) }).catch(() => {});715-}716-717-function resetPlayer() {718- state.liveBaseline = null;719- if (state.hls) { state.hls.destroy(); state.hls = null; }720- video.removeAttribute('src');721- video.load();722-}723-724-async function play(pb, token) {725- resetPlayer();726- // hls.js first, so playback and captions work the same everywhere: it727- // hands the app a stream's captions ahead of time, where macOS's own HLS728- // gives them only as they show. Native HLS when there's no hls.js.729- const tries = [];730- if (window.Hls && window.Hls.isSupported()) tries.push(['hls.js', pb.url], ['hls.js', pb.path]);731- if (video.canPlayType('application/vnd.apple.mpegurl')) tries.push(['native', pb.url]);732- let last = 'no HLS support in this webview';733- for (const [how, src] of tries) {734- if (token !== state.tuneToken) return;735- try {736- await attempt(how, src);737- log('info', `playing ${state.recording ? 'recording ' + state.recording.title : state.current.number} via ${how}`);738- return;739- } catch (e) {740- last = `${how}: ${e.message || e}`;741- log('warn', `${how} ${src}: ${last}`);742- resetPlayer();743- }744- }745- throw new Error(`Could not play the stream (${last})`);746-}747-748-function attempt(how, src) {749- return new Promise((resolve, reject) => {750- let settled = false;751- const done = (err) => {752- if (settled) return;753- settled = true;754- clearTimeout(timer);755- video.removeEventListener('playing', onPlaying);756- video.removeEventListener('error', onError);757- if (err) reject(err); else resolve();758- };759- const onPlaying = () => done();760- const onError = () => done(new Error((video.error && video.error.message) || `media error ${video.error && video.error.code}`));761- const timer = setTimeout(() => done(new Error('timed out')), 15000);762- video.addEventListener('playing', onPlaying);763- video.addEventListener('error', onError);764- if (how === 'native') {765- video.src = src;766- } else {767- // hls.js keeps all it has played, up to WebKit's limit, for rewinding;768- // lite mode keeps 90 s, and loads older video again from the server.769- const h = new window.Hls({ liveSyncDurationCount: 2, maxBufferLength: 12, subtitleDisplay: false, ...(lite ? { backBufferLength: 90 } : {}) });770- state.hls = h;771- h.on(window.Hls.Events.ERROR, (_, d) => { if (d.fatal) done(new Error(d.details)); });772- h.loadSource(src);773- h.attachMedia(video);774- }775- video.play().catch((err) => {776- if (err && err.name === 'NotAllowedError') {777- video.muted = true;778- video.play().catch(() => {});779- const m = keyHint('m', 'M');780- if (m) return toast(`Started muted. Press ${m} for sound.`, 3500, true);781- // A remote has no M: its next key, which lets sound play, turns it on.782- document.addEventListener('keydown', () => { if (!state.userMuted) video.muted = false; }, { once: true, capture: true });783- toast('Started muted. Press any key for sound.', 3500);784- }785- });786- });787-}788-789-function showNoSignal(ch, msg) {790- resetPlayer();791- const ns = $('#nosignal');792- f(ns, 'num').textContent = ch.number;793- f(ns, 'call').textContent = `${displayCall(ch)} ${ch.network ? '| ' + ch.network : ''}`;794- f(ns, 'msg').textContent = msg;795- ns.hidden = false;796-}797-798-function step(dir) {799- const list = state.lineup;800- if (!list.length) return;801- let i = list.findIndex((c) => c.key === (state.current && state.current.key));802- i = (i + dir + list.length) % list.length;803- tune(list[i]);804-}805-806-// ---------- banner ----------807-function chipsFor(p, ch) {808- const out = [];809- for (const fl of (p && p.flags) || []) out.push(`<span class="chip ${fl === 'Live' ? 'live' : fl === 'New' ? 'new' : ''}">${esc(fl)}</span>`);810- if (p && p.stars) out.push(`<span class="chip" title="Gracenote's rating, out of 4">${stars(p.stars)}</span>`);811- if (p && p.rating) out.push(`<span class="chip">${esc(p.rating)}</span>`);812- if (p && p.runtime) out.push(`<span class="chip">${p.runtime >= 60 ? `${Math.floor(p.runtime / 60)} h ${p.runtime % 60 ? `${p.runtime % 60} min` : ''}` : `${p.runtime} min`}</span>`);813- for (const t of (p && p.tags) || []) out.push(`<span class="chip">${esc(t)}</span>`);814- return out.join('');815-}816-817-// imageURL is a picture's address: the server's own ("/guide-art/...")818-// relative to the server, others as they are.819-const imageURL = (u) => (u && u.startsWith('/') ? state.boot.serverUrl + u : u || '');820-821-// stars draws a rating out of 4 in stars, halves included.822-const stars = (n) => '\u2605'.repeat(Math.floor(n)) + (n % 1 >= 0.5 ? '\u00bd' : '');823-824-function episodeLine(p) {825- if (!p) return '';826- const se = p.season && p.episode ? `S${p.season} E${p.episode}` : '';827- // A repeat says when it first aired, which says the year too.828- const first = p.originalAirDate && !(p.flags || []).includes('New') ? `first aired ${firstAired(p.originalAirDate)}` : '';829- const game = p.awayTeam && p.homeTeam && !p.episodeTitle ? `${p.awayTeam} at ${p.homeTeam}` : '';830- return [p.episodeTitle && `"${p.episodeTitle}"`, game, se, !first && p.year, first].filter(Boolean).join(' ');831-}832-833-// firstAired names an original air date ("2019-03-05") as "Mar 5, 2019".834-function firstAired(d) {835- const [y, m, day] = d.split('-').map(Number);836- return new Date(y, m - 1, day).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });837-}838-839-// castLine names a program's cast: "With Missy Peregrym, Zeeko Zaki".840-const castLine = (p, n = 5) => (p && p.cast && p.cast.length ? `With ${p.cast.slice(0, n).map((c) => c.replace(/ \(.*\)$/, '')).join(', ')}` : '');841-842-function renderBanner() {843- updateMediaSession();844- showTimeshift();845- renderOSD();846- const ch = state.current;847- const b = $('#banner');848- if (state.recording) return renderRecordingBanner(b, state.recording);849- if (!ch) return;850- if (ch.weather) return renderWXBanner(b, ch);851- const now = Date.now();852- const p = airingAt(ch, now);853- const nx = p ? nextAfter(ch, p._e - 1) : nextAfter(ch, now);854- f(b, 'num').textContent = ch.number;855- f(b, 'num').classList.toggle('long', ch.number.length > 4);856- f(b, 'call').textContent = displayCall(ch);857- f(b, 'net').textContent = ch.network || '';858- showChannelLogo(b, ch);859- f(b, 'time').textContent = p ? `${clock(p._s)} to ${clock(p._e)}` : '';860- // What's on is being recorded, or will be.861- const rec = p && state.dvrKeys.get(airingKey(ch, p));862- f(b, 'chips').innerHTML = (rec ? `<span class="chip live">${rec.status === 'recording' ? 'Recording' : 'Will record'}</span>` : '') + chipsFor(p, ch);863- f(b, 'title').textContent = p ? p.title : 'No listings';864- f(b, 'ep').textContent = episodeLine(p);865- f(b, 'cast').textContent = castLine(p, 3);866- f(b, 'progress').style.width = p ? `${Math.min(100, ((now - p._s) / (p._e - p._s)) * 100)}%` : '0';867- f(b, 'desc').textContent = p ? p.description || '' : ch.description || '';868- f(b, 'next').textContent = nx ? `${clock(nx._s)} ${nx.title}` : '';869-870- f(b, 'note').textContent = state.note || '';871-872- // The server's own channels have no reception to show. The meter is873- // rebuilt on the next antenna channel.874- if (ch.custom) {875- b.classList.remove('warn');876- f(b, 'meter').hidden = true;877- f(b, 'tier').textContent = ch.category && ch.category !== 'Other' ? ch.category : '';878- f(b, 'tier').style.color = '';879- f(b, 'tx').textContent = OWN_SOURCE[ch.kind] || OWN_SOURCE.folder;880- f(b, 'atsc3').textContent = '';881- return;882- }883-884- // The signal is shown when asked for (I twice, or the controls' I) or885- // when what was measured explains a bad picture.886- const sig = sigOf(sigFor(ch));887- b.classList.toggle('warn', trouble(sig));888- f(b, 'meter').outerHTML = meter(sig, true).replace('class="meter big', 'data-f="meter" class="meter big');889- f(b, 'meter').hidden = false;890- f(b, 'tier').textContent = sig.label;891- f(b, 'tier').style.color = sig.state === 'none' ? '' : sig.color;892- const st = stationFor(ch);893- const mux = state.byRF.get(ch.rf);894- const where = ch.rf ? `RF ${ch.rf} ${(mux && mux.band) || (st && st.band) || ''}`.trim() : '';895- const lines = [];896- if (st) {897- lines.push(`${ch.via ? `On ${esc(st.callSign)}'s transmitter, ` : `${esc(st.callSign)} `}${where}`);898- lines.push(`${st.distanceKm.toFixed(0)} km ${compass(st.bearingDeg)}, ${st.erpKw >= 10 ? st.erpKw.toFixed(0) : st.erpKw.toFixed(1)} kW`);899- } else if (where) {900- lines.push(`${where}, no licensed transmitter on record`);901- }902- lines.push(esc(sigDetail(sig)));903- f(b, 'tx').innerHTML = lines.join('<br>');904- f(b, 'atsc3').textContent = ch.atsc3 ? `Also in ATSC 3.0 on ${ch.atsc3.hostCall} RF ${ch.atsc3.rf}` : '';905-}906-907-// What the server's own channels play, for the banner's details.908-const OWN_SOURCE = {909- folder: 'Videos from your server, on a loop',910- jellyfin: 'From your Jellyfin library, on a schedule',911- youtube: 'YouTube uploads, streamed as they air',912-};913-914-// showChannelLogo puts an own channel's logo under its number in the915-// banner; stations' logos are for the guide.916-function showChannelLogo(b, ch) {917- const el = f(b, 'logo');918- if (!el) return;919- const logo = ch && ch.own && !state.recording ? ch.logo : '';920- el.hidden = !logo;921- el.style.backgroundImage = logo ? `url('${logo}')` : '';922-}923-924-// The banner shows briefly on a channel change and on request. Pressing I925-// cycles: banner, banner with the measured signal, hidden. With the926-// on-screen controls up it stays as long as they do.927-function showBanner(detail = false) {928- renderBanner();929- const b = $('#banner');930- b.classList.toggle('detail', detail);931- b.classList.add('show');932- if (osd.open) return osdTouch();933- return hideBannerSoon(detail ? 12000 : 4000);934-}935-936-// hideBanner puts the banner away, and the controls with it.937-function hideBanner() {938- clearTimeout(state.bannerTimer);939- closeOSD();940- $('#banner').classList.remove('show', 'detail');941-}942-943-function hideBannerSoon(ms = 4000) {944- clearTimeout(state.bannerTimer);945- if (osd.open) return osdTouch();946- state.bannerTimer = setTimeout(hideBanner, ms);947- return undefined;948-}949-950-// cycleBanner is the info key. It also flashes where the picture is, in951-// the playback badge. With the controls up it shows or hides reception.952-function cycleBanner() {953- const b = $('#banner');954- if (osd.open) {955- if (!state.recording) b.classList.toggle('detail');956- return flashPlace();957- }958- if (b.classList.contains('show') && (b.classList.contains('detail') || state.recording)) return hideBanner();959- flashPlace();960- return showBanner(b.classList.contains('show'));961-}962-963-// ---------- on-screen controls ----------964-// OK on live TV brings up the controls over the picture, as video players965-// on a TV do (Media3's PlayerControlView, Leanback, Kodi's OSD, the Live966-// TV app's menu): the channel banner, a timeline of the program and of what967-// can be rewound, a row of labeled buttons, and the dock above as the way968-// to every view. The focus starts on Guide, so OK twice opens the guide;969-// Left and Right on live TV skip and bring them up with the timeline970-// picked. Up and Down move between the rows, Left and Right along one, OK971-// presses. They go after OSD_MS without a key, but stay while paused (as972-// Media3's and Leanback's do); Back puts them away.973-const OSD_MS = 6000;974-const osd = { open: false, timer: 0, tick: 0, last: 'guide', lastButton: 'guide', hover: false };975-976-// The buttons, in order: [action, label]. Labels change with the state.977-const OSD_BUTTONS = [978- ['guide', 'Guide'], ['play', 'Pause'], ['back', '-10 s'], ['ahead', '+30 s'], ['live', 'Live'],979- ['captions', 'Captions'], ['audio', 'Audio'], ['record', 'Record'], ['favorite', 'Favorite'], ['last', 'Last channel'],980-];981-const OSD_KEYS = { guide: 'G', play: 'Space', back: 'Left', ahead: 'Right', live: 'End', captions: 'C', audio: 'V', record: 'R', favorite: 'Shift F', last: 'L' };982-const ICON_TEXT = 'text-anchor="middle" font-family="Plex Mono, Menlo, monospace" font-weight="700"';983-const OSD_ICONS = {984- guide: '<path d="M3 4h18v3.5H3zm0 6.25h7.5v3.5H3zm9.5 0H21v3.5h-8.5zM3 16.5h11V20H3zm13 0h5V20h-5z"/>',985- play: '<path d="M7 4.5v15L19.5 12z"/>',986- pause: '<path d="M6 4.5h4.2v15H6zm7.8 0H18v15h-4.2z"/>',987- back: `<path d="M12 5.5a7.5 7.5 0 1 1-7.5 7.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M12 1.5v8l-5-4z"/><text x="12.4" y="16.2" font-size="7.2" ${ICON_TEXT}>10</text>`,988- ahead: `<path d="M12 5.5a7.5 7.5 0 1 0 7.5 7.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M12 1.5v8l5-4z"/><text x="11.6" y="16.2" font-size="7.2" ${ICON_TEXT}>30</text>`,989- live: '<circle cx="12" cy="12" r="4.2"/><path d="M6.6 6.6a7.6 7.6 0 0 0 0 10.8M17.4 6.6a7.6 7.6 0 0 1 0 10.8" fill="none" stroke="currentColor" stroke-width="2"/>',990- tv: '<rect x="3" y="6.5" width="18" height="12.5" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 2.5l4 3.6 4-3.6" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12.75" r="2.4"/>',991- captions: `<rect x="2.5" y="5" width="19" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="2"/><text x="12" y="15.1" font-size="7.6" ${ICON_TEXT}>CC</text>`,992- audio: '<path d="M3 9h4l5-4.5v15L7 15H3z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.2 5.8a8.8 8.8 0 0 1 0 12.4" fill="none" stroke="currentColor" stroke-width="2"/>',993- record: '<circle cx="12" cy="12" r="7"/>',994- favorite: '<path d="M12 3.6l2.5 5.4 5.9.6-4.4 4 1.3 5.8L12 16.4l-5.3 3 1.3-5.8-4.4-4 5.9-.6z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>',995- favorited: '<path d="M12 3.6l2.5 5.4 5.9.6-4.4 4 1.3 5.8L12 16.4l-5.3 3 1.3-5.8-4.4-4 5.9-.6z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>',996- last: '<path d="M8 3.5 3 8l5 4.5V9.3h9V6.7H8zM16 11.5v3.2H7v2.6h9v3.2l5-4.5z"/>',997-};998-999-const timelineEl = () => f($('#banner'), 'timeline');1000-const osdButtons = () => $$('#banner .osd-bar .ob').filter((b) => !b.hidden);1001-1002-function wireOSD() {1003- const b = $('#banner');1004- f(b, 'osd').innerHTML = OSD_BUTTONS.map(([act, label]) => `<button type="button" class="ob fx" data-act="${act}" title="${esc(label)} (${OSD_KEYS[act]})"><svg viewBox="0 0 24 24" aria-hidden="true"></svg><span>${esc(label)}</span></button>`).join('');1005- f(b, 'osd').addEventListener('click', (e) => {1006- const btn = e.target.closest('[data-act]');1007- if (!btn) return;1008- osdTouch();1009- osdAct(btn.dataset.act);1010- });1011- b.addEventListener('focusin', (e) => {1012- const btn = e.target.closest('.ob');1013- if (btn) osd.last = osd.lastButton = btn.dataset.act;1014- else if (e.target === timelineEl()) osd.last = 'timeline';1015- });1016- // The pointer on the controls keeps them up.1017- b.addEventListener('mouseenter', () => { osd.hover = document.body.classList.contains('mouse-active'); osdTouch(); });1018- b.addEventListener('mouseleave', () => { osd.hover = false; osdTouch(); });1019- // A click on the timeline goes there.1020- timelineEl().addEventListener('click', (e) => {1021- const bar = $('.tl-bar', timelineEl()).getBoundingClientRect();1022- if (bar.width > 0) seekTimeline((e.clientX - bar.left) / bar.width);1023- });1024-}1025-1026-// openOSD brings up the controls, with the focus on focus ("guide", another1027-// button's action, or "timeline"), or on none for a pointer.1028-function openOSD(focus = 'guide') {1029- if (state.view !== 'tv' || (!state.current && !state.recording)) return;1030- if (!osd.open) {1031- osd.open = true;1032- document.body.classList.add('osd-open');1033- clearTimeout(state.bannerTimer);1034- osd.tick = setInterval(renderTimeline, 1000);1035- }1036- renderBanner();1037- $('#banner').classList.add('show', 'osd');1038- if (focus) osdFocus(focus);1039- osdTouch();1040-}1041-1042-function closeOSD() {1043- if (!osd.open) return;1044- osd.open = false;1045- clearTimeout(osd.timer);1046- clearInterval(osd.tick);1047- osd.tick = 0;1048- osd.hover = false;1049- document.body.classList.remove('osd-open');1050- const b = $('#banner');1051- b.classList.remove('osd', 'tl-on');1052- if (state.dock >= 0 && state.view === 'tv') closeDock(false);1053- blurIn(b);1054-}1055-1056-// osdTouch starts the controls' time over: a key was pressed.1057-function osdTouch() {1058- clearTimeout(osd.timer);1059- if (!osd.open || osd.hover || pausedStream()) return;1060- osd.timer = setTimeout(hideBanner, OSD_MS);1061-}1062-1063-const hasPicture = () => !!(video.src || state.hls) && !video.hidden && $('#nosignal').hidden;1064-const pausedStream = () => hasPicture() && video.paused;1065-1066-// osdFocus focuses a button by its action, or the timeline: the nearest1067-// there is when that one isn't showing.1068-function osdFocus(target) {1069- const tl = timelineEl();1070- if (target === 'timeline' && !tl.hidden) return focusEl(tl);1071- const buttons = osdButtons();1072- const want = target === 'timeline' ? osd.lastButton : target;1073- return focusEl(buttons.find((b) => b.dataset.act === want) || buttons.find((b) => b.dataset.act === 'play') || buttons[0]);1074-}1075-1076-// osdKey handles a key while the controls are up; it reports whether it1077-// did. Keys it leaves (letters, Space, channel up and down) do what they1078-// do on TV.1079-function osdKey(e) {1080- const k = e.key;1081- const el = document.activeElement;1082- const onTl = el === timelineEl();1083- const btn = el && el.closest ? el.closest('#banner .ob') : null;1084- if ((k === 'ArrowLeft' || k === 'ArrowRight') && e.shiftKey) {1085- e.preventDefault();1086- skip(k === 'ArrowLeft' ? -60 : 60);1087- renderTimeline();1088- return true;1089- }1090- if (k === 'Escape') {1091- // A channel number being typed goes first.1092- e.preventDefault();1093- if (state.entry) cancelEntry(); else hideBanner();1094- return true;1095- }1096- if (!onTl && !btn) {1097- // Up by a pointer, or the focus went: the first key finds it.1098- if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(k)) return false;1099- e.preventDefault();1100- osdFocus(osd.last);1101- return true;1102- }1103- switch (k) {1104- case 'ArrowLeft': case 'ArrowRight':1105- if (onTl) {1106- skip(k === 'ArrowLeft' ? -10 : 30);1107- renderTimeline();1108- } else {1109- focusEl(neighbor(osdButtons(), btn, k === 'ArrowLeft' ? -1 : 1));1110- }1111- break;1112- case 'ArrowUp':1113- if (btn && !timelineEl().hidden) focusEl(timelineEl());1114- else openDock();1115- break;1116- case 'ArrowDown':1117- if (onTl) osdFocus(osd.lastButton);1118- break;1119- case 'Enter':1120- if (onTl) togglePause(); else btn.click();1121- break;1122- default: return false;1123- }1124- e.preventDefault();1125- return true;1126-}1127-1128-function osdAct(act) {1129- const after = (x) => Promise.resolve(x).then(renderOSD);1130- switch (act) {1131- case 'guide': return setView('guide');1132- case 'play': return togglePause();1133- case 'back': skip(-10); return renderTimeline();1134- case 'ahead': skip(30); return renderTimeline();1135- case 'live': return state.recording ? state.current && tune(state.current) : goLive();1136- case 'captions': return after(toggleCaptions());1137- case 'audio': return after(cycleAudio());1138- case 'record': return after(recordNow('once'));1139- case 'favorite': return after(toggleFavorite(state.current));1140- case 'last': return state.previous && tune(state.previous);1141- default: return null;1142- }1143-}1144-1145-// renderOSD shows the buttons that apply now, labeled for the state, and1146-// the timeline. The focus moves on from a button that went.1147-function renderOSD() {1148- if (!osd.open) return;1149- const b = $('#banner');1150- const ch = state.current;1151- const rec = state.recording;1152- const now = Date.now();1153- // Video controls for a stream, not the weather channel's display or a1154- // channel that failed to tune.1155- const stream = $('#nosignal').hidden && !!(rec || (ch && !ch.weather));1156- const p = !rec && ch && !ch.own ? airingAt(ch, now) : null;1157- const it = p && state.dvrKeys.get(airingKey(ch, p));1158- const fav = !rec && isFav(ch);1159- const was = document.activeElement;1160- const before = osdButtons();1161- const at = before.indexOf(was);1162- const set = (act, on, label, icon = act, lit = false) => {1163- const btn = $(`.ob[data-act="${act}"]`, b);1164- btn.hidden = !on;1165- if (!on) return;1166- const span = btn.lastElementChild;1167- if (span.textContent !== label) span.textContent = label;1168- if (btn.dataset.icon !== icon) {1169- btn.dataset.icon = icon;1170- btn.firstElementChild.innerHTML = OSD_ICONS[icon];1171- }1172- btn.classList.toggle('on', lit);1173- if (act !== 'guide' && act !== 'last') btn.setAttribute('aria-pressed', String(lit));1174- };1175- set('guide', true, 'Guide');1176- set('play', stream, video.paused && hasPicture() ? 'Play' : 'Pause', video.paused && hasPicture() ? 'play' : 'pause');1177- set('back', stream, '-10 s');1178- set('ahead', stream, '+30 s');1179- set('live', !!rec || stream, rec ? 'Live TV' : 'Live', rec ? 'tv' : 'live');1180- set('captions', stream, 'Captions', 'captions', stream && captionsWanted());1181- set('audio', stream && audioTracks().length > 1, 'Audio');1182- // Recording is for the antenna's channels, on a server with a DVR.1183- set('record', !!(state.info.dvr && p && p._e > now), it ? (it.status === 'recording' ? 'Recording' : 'Will record') : 'Record', 'record', !!it);1184- set('favorite', !rec && !!ch, 'Favorite', fav ? 'favorited' : 'favorite', fav);1185- set('last', !rec && !!state.previous, 'Last channel');1186- const last = $('.ob[data-act="last"]', b);1187- last.title = state.previous ? `Back to ${state.previous.number} ${displayCall(state.previous)} (L)` : '';1188- renderTimeline();1189- if (was && b.contains(was) && (was.hidden || document.activeElement !== was)) {1190- const after = osdButtons();1191- if (was === timelineEl() || at < 0) osdFocus(osd.lastButton);1192- else focusEl(after[Math.min(at, after.length - 1)]);1193- }1194-}1195-1196-// timelineModel is what the timeline shows, in one scale: for live TV, ms1197-// since the epoch, from the program's start (or whatever can be rewound1198-// before it) to its end; for a recording, seconds into it.1199-function timelineModel() {1200- if (!hasPicture()) return null;1201- const r = video.seekable;1202- const has = r && r.length > 0;1203- if (state.recording) {1204- const off = state.recOffset || 0;1205- const pos = off + (video.currentTime || 0);1206- const dur = Math.max(state.recording.duration || 0, pos);1207- if (!dur) return null;1208- return {1209- lo: 0, hi: dur, past: 0, edge: null, head: pos, bufLo: has ? off + r.start(0) : pos, bufHi: has ? off + r.end(r.length - 1) : pos,1210- left: mmss(pos), right: mmss(dur), at: mmss(pos), live: false,1211- };1212- }1213- const now = Date.now();1214- const behind = behindLive();1215- const head = now - behind * 1000;1216- const bufLo = has ? now - Math.max(0, livePoint() - r.start(0)) * 1000 : head;1217- const p = airingAt(state.current, now);1218- const lo = Math.min(p ? p._s : now - 15 * MIN, bufLo, head);1219- const hi = p ? p._e : now;1220- const live = behind <= 4;1221- return { lo, hi, past: now, edge: now, head, bufLo, bufHi: now, left: clock(lo), right: p ? clock(hi) : 'Now', at: live ? 'Live' : `-${mmss(behind)}`, live };1222-}1223-1224-function renderTimeline() {1225- if (!osd.open) return;1226- const b = $('#banner');1227- const tl = timelineEl();1228- const m = timelineModel();1229- const show = !!m;1230- if (tl.hidden === show) {1231- // The timeline goes (a failed tune, the weather channel): its focus1232- // moves down to the buttons.1233- const had = document.activeElement === tl;1234- tl.hidden = !show;1235- if (had && !show) osdFocus(osd.lastButton);1236- }1237- b.classList.toggle('tl-on', show);1238- const liveBtn = $('.ob[data-act="live"]', b);1239- if (liveBtn) liveBtn.classList.toggle('at', !!(m && m.live));1240- if (!m) return;1241- const span = m.hi - m.lo || 1;1242- const pct = (t) => `${Math.max(0, Math.min(100, ((t - m.lo) / span) * 100)).toFixed(2)}%`;1243- const bar = $('.tl-bar', tl);1244- const [past, buf, edge, head] = bar.children;1245- past.style.width = pct(m.past);1246- buf.style.left = pct(m.bufLo);1247- buf.style.width = `calc(${pct(m.bufHi)} - ${pct(m.bufLo)})`;1248- edge.hidden = m.edge == null;1249- if (m.edge != null) edge.style.left = pct(m.edge);1250- head.style.left = pct(m.head);1251- f(tl, 'tl-lo').textContent = m.left;1252- f(tl, 'tl-hi').textContent = m.right;1253- f(tl, 'tl-at').textContent = m.at;1254- tl.setAttribute('aria-valuetext', m.at);1255-}1256-1257-// seekTimeline goes to a fraction of the timeline (a click on it).1258-function seekTimeline(frac) {1259- const m = timelineModel();1260- const r = video.seekable;1261- if (!m || !r || !r.length) return;1262- const t = m.lo + Math.max(0, Math.min(1, frac)) * (m.hi - m.lo);1263- // Both scales count forward with the picture: ms for live, s for a recording.1264- const delta = state.recording ? t - m.head : (t - m.head) / 1000;1265- const from = video.currentTime;1266- video.currentTime = Math.min(Math.max(from + delta, r.start(0)), state.recording ? r.end(r.length - 1) : livePoint());1267- showTimeshift();1268- renderTimeline();1269- osdTouch();1270-}1271-1272-1273-// ---------- number entry ----------1274-function entryKey(k) {1275- state.entry = (state.entry + k).slice(0, 6);1276- const e = $('#entry');1277- e.textContent = state.entry;1278- e.classList.add('show');1279- clearTimeout(state.entryTimer);1280- state.entryTimer = setTimeout(commitEntry, 2200);1281-}1282-1283-function commitEntry() {1284- clearTimeout(state.entryTimer);1285- const n = state.entry;1286- state.entry = '';1287- $('#entry').classList.remove('show');1288- if (!n) return;1289- const ch = findChannel(n);1290- if (ch) tune(ch); else toast(`No channel ${n} in your lineup`);1291-}1292-1293-function cancelEntry() {1294- clearTimeout(state.entryTimer);1295- state.entry = '';1296- $('#entry').classList.remove('show');1297-}1298-1299-// ---------- keyboard ----------1300-// Text fields keep their keys (but Esc).1301-const TEXT_INPUT = 'input:not([type=checkbox]):not([type=radio]), textarea, select';1302-1303-// The media keys of a TV remote (over HDMI-CEC, or an Android TV remote)1304-// or a keyboard, as the keys the views handle: [key, shift]. Page Down is1305-// channel up.1306-const KEY_ALIASES = {1307- MediaPlayPause: [' '], MediaPlay: [' '], MediaPause: [' '],1308- MediaFastForward: ['ArrowRight'], MediaRewind: ['ArrowLeft'],1309- MediaTrackNext: ['PageDown'], MediaTrackPrevious: ['PageUp'],1310- ChannelUp: ['PageDown'], ChannelDown: ['PageUp'],1311- MediaRecord: ['r'], BrowserBack: ['Escape'], GoBack: ['Escape'],1312- Guide: ['g'], Info: ['i'], ClosedCaptionToggle: ['c'], MediaLast: ['l'], MediaAudioTrack: ['v'],1313-};1314-1315-// pressKey does what a key does, for a controller button or a media key:1316-// an untrusted keydown runs the same handler and nothing else (no button1317-// is pressed, no text typed).1318-function pressKey(key, { shiftKey = false, repeat = false } = {}) {1319- (document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey, repeat, bubbles: true, cancelable: true }));1320-}1321-1322-// Keys held down: the Android TV app's WebView sends a held key again1323-// without marking it a repeat, so another keydown before its keyup is one.1324-const held = new Map(); // key: when its last keydown came1325-const HELD_MS = 700;1326-1327-function wireKeys() {1328- document.addEventListener('keydown', (e) => {1329- let again = e.repeat;1330- if (e.isTrusted) {1331- setPrompts(false);1332- const at = held.get(e.key);1333- again = again || (at !== undefined && e.timeStamp - at < HELD_MS);1334- held.set(e.key, e.timeStamp);1335- }1336- const alias = KEY_ALIASES[e.key];1337- if (alias) {1338- e.preventDefault();1339- return pressKey(alias[0], { shiftKey: !!alias[1], repeat: again });1340- }1341- const typing = e.target.matches && e.target.matches(TEXT_INPUT);1342- if (document.body.classList.contains('booting')) return bootKey(e, typing);1343- if (typing && e.key !== 'Escape') return;1344- if (e.metaKey || e.ctrlKey || e.altKey) return;1345- const k = e.key;1346- // A held OK, or Space, acts once; held, OK is a long press.1347- if ((k === 'Enter' || k === ' ') && again) {1348- e.preventDefault();1349- if (k === 'Enter') holdLong();1350- return;1351- }1352- if (osd.open) osdTouch();1353- if (state.dock >= 0 && dockKey(e)) return;1354- if (typing) {1355- // Esc puts a field away, back to its row.1356- e.preventDefault();1357- e.target.blur();1358- return restoreFocus();1359- }1360- if (state.view === 'settings' && settingsKey(e)) return;1361- if (state.view === 'tv' && osd.open && osdKey(e)) return;1362- if (k === 'Escape') {1363- if (state.entry) return cancelEntry();1364- if (viewBack()) return;1365- if (state.view !== 'tv') return setView('tv');1366- if (state.recording && state.current) return tune(state.current);1367- return hideBanner();1368- }1369- if (k === 'g' || k === 'G') return setView(state.view === 'guide' ? 'tv' : 'guide');1370- if ((k === 'a' || k === 'A') && state.info.antenna !== false) {1371- if (receptionShown()) return setView('tv');1372- if (state.view !== 'settings') setView('settings');1373- return openSettings('reception', 'list');1374- }1375- if ((k === 'd' || k === 'D') && state.info.dvr) return setView(state.view === 'recordings' ? 'tv' : 'recordings');1376- if ((k === 'w' || k === 'W') && state.view !== 'recordings') return setView(state.view === 'weather' ? 'tv' : 'weather');1377- if (k === ',' || k === 's' || k === 'S') return setView(state.view === 'settings' ? 'tv' : 'settings');1378- if (k === 'm' || k === 'M') {1379- const onWX = state.current && state.current.weather;1380- const muted = !(onWX ? state.userMuted : video.muted);1381- state.userMuted = muted;1382- if (!state.wxPreview) video.muted = muted;1383- wxMusic.muted = muted;1384- return toast(muted ? 'Muted' : 'Sound on', 1200);1385- }1386- if ((k === 'u' || k === 'U') && state.lastHidden) {1387- const key = state.lastHidden;1388- state.lastHidden = null;1389- return unhide([key]).then(() => toast('Channel shown again', 1800));1390- }1391- if (k === 'f') return toggleFullscreen();1392-1393- if (state.view === 'guide') return guideKey(e);1394- if (state.view === 'recordings') return recordingsKey(e);1395- if (state.view === 'weather') return weatherKey(e);1396- if (state.view !== 'tv') return;1397-1398- if (/^[0-9]$/.test(k) || (k === '.' && state.entry)) { e.preventDefault(); return entryKey(k); }1399- switch (k) {1400- case 'ArrowUp': case 'PageUp': e.preventDefault(); return step(-1);1401- case 'ArrowDown': case 'PageDown': e.preventDefault(); return step(1);1402- // Left and Right skip, and bring up the controls with the timeline1403- // picked, for more of the same.1404- case 'ArrowLeft': e.preventDefault(); skip(e.shiftKey ? -60 : -10); return openOSD('timeline');1405- case 'ArrowRight': e.preventDefault(); skip(e.shiftKey ? 60 : 30); return openOSD('timeline');1406- case ' ': e.preventDefault(); return togglePause();1407- case 'End': e.preventDefault(); return goLive();1408- case 'Enter': e.preventDefault(); return state.entry ? commitEntry() : openOSD();1409- case 'i': case 'I': return cycleBanner();1410- case 'l': case 'L': case 'Backspace': return state.previous && tune(state.previous);1411- case 'r': return recordNow('once');1412- case 'R': return recordNow('series', false);1413- case 'n': case 'N': return recordNow('series', true);1414- case 'c': case 'C': return toggleCaptions();1415- case 'v': case 'V': return cycleAudio();1416- case 'F': return toggleFavorite(state.current);1417- default:1418- }1419- });1420- document.addEventListener('keyup', (e) => {1421- held.delete(e.key);1422- holdEnd(e);1423- });1424- window.addEventListener('blur', () => held.clear());1425-}1426-1427-// ---------- holding OK ----------1428-// Holding OK (or Enter) for more, as on Android TV and in Kodi, where a long1429-// press brings up a context menu: in the guide, a program's actions. A1430-// press acts on its keyup; a hold once the key comes again (Android sends1431-// a held key again at its long-press timeout, a keyboard repeats) or it's1432-// been down HOLD_MS. A controller's button (pressKey) has no keyup, and1433-// acts at once.1434-const HOLD_MS = 500;1435-const hold = { timer: 0, short: null, long: null };1436-1437-function holdStart(short, long) {1438- clearTimeout(hold.timer);1439- hold.short = short;1440- hold.long = long;1441- hold.timer = setTimeout(holdLong, HOLD_MS);1442-}1443-1444-function holdLong() {1445- if (!hold.timer) return;1446- clearTimeout(hold.timer);1447- hold.timer = 0;1448- const long = hold.long;1449- hold.short = null;1450- hold.long = null;1451- if (long) long();1452-}1453-1454-function holdEnd(e) {1455- if (e.key !== 'Enter' || !hold.timer) return;1456- clearTimeout(hold.timer);1457- hold.timer = 0;1458- const short = hold.short;1459- hold.short = null;1460- hold.long = null;1461- if (short) short();1462-}1463-1464-// bootKey is a key while starting: on the connect prompt the arrows move1465-// between its fields and Connect (Up and Down only, while typing), and OK1466-// on a field puts the cursor in it.1467-function bootKey(e, typing) {1468- const form = $('.boot-connect');1469- if (form.hidden) {1470- if (!typing && (e.key === 'r' || e.key === 'R')) location.reload();1471- return;1472- }1473- const items = $$('.fx', form);1474- const el = document.activeElement;1475- const i = items.indexOf(el && el.closest ? el.closest('.fx') : null);1476- if (e.key === 'Enter' && el && el.matches('.boot-field')) {1477- e.preventDefault();1478- const input = $('input', el);1479- input.focus();1480- input.select();1481- return;1482- }1483- const dir = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key];1484- if (!dir || (typing && (e.key === 'ArrowLeft' || e.key === 'ArrowRight'))) return;1485- e.preventDefault();1486- bootField(items[i < 0 ? 0 : Math.max(0, Math.min(items.length - 1, i + dir))]);1487-}1488-1489-// bootField moves to a field of the connect prompt: on a TV to its row, as1490-// a field with the cursor in it would bring up the keyboard at the next1491-// key; elsewhere into it, to type.1492-function bootField(el) {1493- if (!el) return;1494- const input = el.matches('.boot-field') && !androidTV ? $('input', el) : null;1495- if (input) { input.focus(); input.select(); } else focusEl(el);1496-}1497-1498-// On the Android TV app the keyboard comes up over the page (the visual1499-// viewport shrinks); put away with Back, it leaves its field with the1500-// cursor, where the next arrow would bring it up again. So the field lets1501-// go then, back to its row, as Esc does: a setting's text goes back to1502-// what it was (Enter, the keyboard's own key, keeps it).1503-const ime = { open: false };1504-if (androidTV && window.visualViewport) {1505- visualViewport.addEventListener('resize', () => {1506- const el = document.activeElement;1507- const typing = !!(el && el.matches && el.matches(TEXT_INPUT));1508- if (visualViewport.height < innerHeight * 0.9) { ime.open = typing; return; }1509- if (!ime.open) return;1510- ime.open = false;1511- if (!typing) return;1512- if (el.dataset.was !== undefined) el.value = el.dataset.was;1513- const row = el.closest('.fx');1514- el.blur();1515- if (row) focusEl(row); else restoreFocus();1516- });1517-}1518-1519-// airwavesBack is the Android TV app's Back key: it does what Esc does and1520-// tells whether that went back from anything. False (live TV with nothing1521-// open, or still starting) lets the app close.1522-window.airwavesBack = () => {1523- if (!state.settings || document.body.classList.contains('booting')) return false;1524- const el = document.activeElement;1525- const typing = !!(el && el.matches && el.matches(TEXT_INPUT));1526- const open = state.dock >= 0 || typing || !!state.entry || state.view !== 'tv' || osd.open1527- || !!(state.recording && state.current) || $('#banner').classList.contains('show');1528- if (!open) return false;1529- pressKey('Escape');1530- return true;1531-};1532-1533-// In the background (the Home key) the Android TV app's live TV stops, so1534-// the server's tuner is free, and tunes again on return; a recording1535-// pauses where it is.1536-document.addEventListener('visibilitychange', () => {1537- if (!androidTV || !state.settings || document.body.classList.contains('booting')) return;1538- if (document.hidden && state.recording) {1539- video.pause();1540- } else if (document.hidden && state.current) {1541- state.away = true;1542- state.tuneToken++;1543- resetPlayer();1544- wxMusic.pause();1545- api().StopTV();1546- } else if (!document.hidden && state.away) {1547- state.away = false;1548- if (!state.recording) tune(state.current, { quiet: true });1549- }1550-});1551-1552-// ---------- dock ----------1553-// Without letter keys (a TV remote, a controller) the dock leads to every1554-// view: Left from its left edge, or Up from the top of a view, moves to1555-// it, and on TV it's the row above the on-screen controls. Left and Right1556-// pick, Enter opens, Esc or Down goes back to where the focus was.1557-const dockButtons = () => $$('#dock button').filter((b) => b.offsetParent);1558-1559-function openDock() {1560- const el = document.activeElement;1561- if (el && el !== document.body && !el.matches(TEXT_INPUT)) el.blur();1562- state.dock = Math.max(0, dockButtons().findIndex((b) => b.dataset.view === state.view));1563- renderDock();1564-}1565-1566-function closeDock(restore = true) {1567- state.dock = -1;1568- renderDock();1569- if (restore) restoreFocus();1570-}1571-1572-function renderDock() {1573- document.body.classList.toggle('dock-nav', state.dock >= 0);1574- dockButtons().forEach((b, i) => b.classList.toggle('pick', i === state.dock));1575-}1576-1577-// dockKey handles a key while the dock is picked; other keys leave it.1578-function dockKey(e) {1579- const buttons = dockButtons();1580- switch (e.key) {1581- case 'ArrowLeft': state.dock = Math.max(0, state.dock - 1); break;1582- case 'ArrowRight': state.dock = Math.min(buttons.length - 1, state.dock + 1); break;1583- case 'ArrowUp': break;1584- case 'Enter': case ' ': {1585- const b = buttons[state.dock];1586- closeDock(false);1587- if (b) setView(b.dataset.view);1588- e.preventDefault();1589- return true;1590- }1591- case 'ArrowDown':1592- // In the guide the filters sit just below the dock.1593- if (state.view === 'guide') { closeDock(false); guideZone('filters'); } else closeDock();1594- e.preventDefault();1595- return true;1596- case 'Escape': closeDock(); e.preventDefault(); return true;1597- default: closeDock(); return false;1598- }1599- e.preventDefault();1600- renderDock();1601- return true;1602-}1603-1604-// ---------- focus ----------1605-// Where the remote, a controller or the arrow keys are is always marked,1606-// the same way in every view (style.css, Focus). Buttons, fields and the1607-// weather's parts take the page's focus (.fx); the guide's grid and the1608-// lists of Recordings, Reception and Settings mark their pick with a class,1609-// while that part of the view has the focus (its data-zone). Only keys1610-// focus: a click leaves the focus where it was (wireMouse).1611-const isShown = (el) => !!el && !el.hidden && el.getClientRects().length > 0;1612-1613-function focusEl(el, scroll = true) {1614- if (!el) return false;1615- if (document.activeElement !== el) el.focus({ preventScroll: true });1616- if (scroll && el.closest('.wx-main, .r-grid, .r-list, .s-list, .a-tablewrap')) el.scrollIntoView({ block: 'nearest' });1617- return document.activeElement === el;1618-}1619-1620-// blurIn takes the focus off whatever has it inside root.1621-function blurIn(root) {1622- const el = document.activeElement;1623- if (el && el !== document.body && root.contains(el)) el.blur();1624-}1625-1626-// neighbor is the item dir (-1 or 1) from el in items, or null past either1627-// end: rows of buttons stop at their ends.1628-function neighbor(items, el, dir) {1629- const i = items.indexOf(el);1630- return i < 0 ? items[0] || null : items[i + dir] || null;1631-}1632-1633-// restoreFocus puts the focus back where it was in the view, after the dock1634-// or a field let it go.1635-function restoreFocus() {1636- if (state.dock >= 0) return;1637- if (state.view === 'tv' && osd.open) osdFocus(osd.last);1638- else if (state.view === 'guide') guideZone(guideUI.zone);1639- else if (state.view === 'recordings') recZone(recUI.zone);1640- else if (state.view === 'settings') settingsRefocus();1641- else if (state.view === 'weather') wxFocus();1642-}1643-1644-// viewBack is Back in an inner part of a view (a program's or a1645-// recording's actions, the guide's filters): it goes to the view's main1646-// part, and reports whether it did.1647-function viewBack() {1648- if (state.view === 'guide' && state.recPick) { closeRecordPick(); return true; }1649- if (state.view === 'guide' && guideUI.zone !== 'grid') { guideZone('grid'); return true; }1650- if (state.view === 'recordings' && recUI.zone === 'actions') { recZone('list'); return true; }1651- // In a tab of Settings, Back goes to the tabs, as on Android TV.1652- if (state.view === 'settings' && settingsUI.zone === 'list') { settingsZone('tabs'); return true; }1653- return false;1654-}1655-1656-// ---------- game controllers ----------1657-// A game controller does what the keyboard does: each button stands for a1658-// key and goes through the same handler. Indexes are the standard gamepad1659-// mapping (A, B, X, Y, LB, RB, LT, RT, Back, Start, L3, R3, d-pad); Guide is1660-// Steam's. WebKit lists a controller once a button is pressed; while one is1661-// connected the pads are read every frame, and not while the window is1662-// hidden. Pads are merged, so one controller seen twice presses once.1663-//1664-// The pads are read raw, so Steam's own menus don't hide them from the app.1665-// Guide opens Steam's menu, and Guide with A its quick access menu, which1666-// leave the window focused under gamescope: from Guide until Guide again or1667-// B (which closes them), the pads are Steam's. Nothing acts while the window1668-// is unfocused either, and a button held when the app gets the pads back1669-// acts only once let go and pressed again.1670-const PAD_KEYS = [1671- ['Enter'], ['Escape'], ['i'], ['g'],1672- ['PageUp'], ['PageDown'], ['ArrowLeft', true], ['ArrowRight', true],1673- ['w'], [','], ['m'], [' '],1674- ['ArrowUp'], ['ArrowDown'], ['ArrowLeft'], ['ArrowRight'],1675-];1676-const PAD_ARROWS = 12; // the d-pad (and the left stick) repeat when held1677-// A direction held longer than this stops repeating until it's let go: a1678-// pad whose state froze mid-press (asleep, or taken by Steam) would1679-// otherwise move the guide or the dock on its own.1680-const PAD_REPEAT_MAX = 4000;1681-const pad = { raf: 0, held: [], stick: -1, greeted: false, guide: false, away: false, mute: false };1682-const PAD_GUIDE = 16;1683-const PAD_B = 1;1684-1685-function wirePads() {1686- if (!navigator.getGamepads) return;1687- window.addEventListener('gamepadconnected', (e) => {1688- log('info', `controller connected: ${e.gamepad.id} (${e.gamepad.mapping || 'no'} mapping)`);1689- setPadFamily(e.gamepad.id);1690- if (!pad.greeted) {1691- pad.greeted = true;1692- toast('Controller connected', 2500);1693- }1694- padWatch();1695- });1696- window.addEventListener('gamepaddisconnected', (e) => log('info', `controller disconnected: ${e.gamepad.id}`));1697- document.addEventListener('visibilitychange', () => {1698- if (!document.hidden) {1699- pad.mute = true;1700- return padWatch();1701- }1702- cancelAnimationFrame(pad.raf);1703- pad.raf = 0;1704- });1705- window.addEventListener('focus', () => {1706- pad.away = false;1707- pad.mute = true;1708- });1709- padWatch(); // pads already listed, after a reload1710-}1711-1712-// padWatch reads the pads on the next frame, unless the window is hidden.1713-function padWatch() {1714- if (!pad.raf && !document.hidden) pad.raf = requestAnimationFrame(padPoll);1715-}1716-1717-function padPoll(now) {1718- pad.raf = 0;1719- const pads = [...navigator.getGamepads()].filter(Boolean);1720- if (!pads.length) {1721- pad.held = [];1722- pad.stick = -1;1723- return;1724- }1725- const down = padButtons(pads);1726- const guide = pads.some((p) => p.buttons[PAD_GUIDE] && p.buttons[PAD_GUIDE].pressed);1727- if (guide && !pad.guide) pad.away = !pad.away;1728- else if (pad.away && pad.held[PAD_B] && !down[PAD_B]) pad.away = false;1729- pad.guide = guide;1730- const live = !pad.away && !pad.mute && document.hasFocus();1731- pad.mute = false;1732- down.forEach((on, i) => {1733- const h = pad.held[i];1734- if (!on) {1735- pad.held[i] = null;1736- } else if (!live) {1737- pad.held[i] = h || { next: Infinity }; // acts once let go and pressed again1738- } else if (!h) {1739- pad.held[i] = { next: now + 400, since: now };1740- padPress(i, false);1741- } else if (i >= PAD_ARROWS && now >= h.next && now - h.since < PAD_REPEAT_MAX) {1742- h.next = now + 120;1743- padPress(i, true);1744- }1745- });1746- padWatch();1747-}1748-1749-// padButtons merges the pads' buttons, with the left stick as the d-pad: it1750-// points along its stronger axis once pushed past 0.6, until back under 0.35.1751-function padButtons(pads) {1752- const down = PAD_KEYS.map(() => false);1753- let x = 0;1754- let y = 0;1755- for (const p of pads) {1756- for (let i = 0; i < down.length && i < p.buttons.length; i++) {1757- if (p.buttons[i].pressed || p.buttons[i].value > 0.5) down[i] = true;1758- }1759- if (Math.abs(p.axes[0] || 0) > Math.abs(x)) x = p.axes[0];1760- if (Math.abs(p.axes[1] || 0) > Math.abs(y)) y = p.axes[1];1761- }1762- const along = { 12: -y, 13: y, 14: -x, 15: x };1763- if (pad.stick >= 0 && along[pad.stick] < 0.35) pad.stick = -1;1764- if (pad.stick < 0 && Math.max(Math.abs(x), Math.abs(y)) > 0.6) {1765- pad.stick = Math.abs(x) > Math.abs(y) ? (x < 0 ? 14 : 15) : (y < 0 ? 12 : 13);1766- }1767- if (pad.stick >= 0) down[pad.stick] = true;1768- return down;1769-}1770-1771-function padPress(i, repeat) {1772- setPrompts(true);1773- // On the TV, A plays and pauses: the guide has its own button, and the1774- // remote's OK (Enter) brings up the controls. With them up, A presses.1775- if (i === 0 && state.view === 'tv' && !state.entry && !osd.open && state.dock < 0) return pressKey(' ', { repeat });1776- const [key, shiftKey] = PAD_KEYS[i];1777- pressKey(key, { shiftKey: !!shiftKey, repeat });1778-}1779-1780-// ---------- key and button hints ----------1781-// Hints name keys, or a controller's buttons once one was used (Kenney's1782-// CC0 Input Prompts, in vendor/kenney-prompts), until a key is pressed1783-// again. Glyphs by the key a button stands for (PAD_KEYS); the face buttons1784-// are where the standard mapping puts them, so a Switch pad's read B A Y X.1785-const GLYPHS = {1786- xbox: ['xbox_button_a', 'xbox_button_b', 'xbox_button_x', 'xbox_button_y', 'xbox_lb', 'xbox_rb', 'xbox_lt', 'xbox_rt',1787- 'xbox_button_view', 'xbox_button_menu', 'xbox_ls', 'xbox_rs', 'xbox_dpad_none', 'xbox_dpad_vertical', 'xbox_dpad_horizontal'],1788- playstation: ['playstation_button_cross', 'playstation_button_circle', 'playstation_button_square', 'playstation_button_triangle',1789- 'playstation_trigger_l1', 'playstation_trigger_r1', 'playstation_trigger_l2', 'playstation_trigger_r2', 'playstation5_button_create',1790- 'playstation5_button_options', 'playstation_button_l3', 'playstation_button_r3', 'playstation_dpad_none', 'playstation_dpad_vertical', 'playstation_dpad_horizontal'],1791- switch: ['switch_button_b', 'switch_button_a', 'switch_button_y', 'switch_button_x', 'switch_button_l', 'switch_button_r', 'switch_button_zl',1792- 'switch_button_zr', 'switch_button_minus', 'switch_button_plus', 'switch_stick_l_press', 'switch_stick_r_press', 'switch_dpad_none', 'switch_dpad_vertical', 'switch_dpad_horizontal'],1793- steamdeck: ['steamdeck_button_a', 'steamdeck_button_b', 'steamdeck_button_x', 'steamdeck_button_y', 'steamdeck_button_l1', 'steamdeck_button_r1',1794- 'steamdeck_button_l2', 'steamdeck_button_r2', 'steamdeck_button_view', 'steamdeck_button_options', 'steamdeck_stick_l_press', 'steamdeck_stick_r_press',1795- 'steamdeck_dpad_none', 'steamdeck_dpad_vertical', 'steamdeck_dpad_horizontal'],1796-};1797-// The key each glyph above stands for, in order.1798-const GLYPH_KEYS = ['Enter', 'Escape', 'i', 'g', 'PageUp', 'PageDown', 'ShiftLeft', 'ShiftRight', 'w', ',', 'm', ' ', 'Arrows', 'UpDown', 'LeftRight'];1799-const prompts = { pad: false, family: 'xbox', detected: 'xbox' };1800-1801-// An 8BitDo pad's X and Y arrive swapped (its X, on the left, reads as the1802-// standard mapping's top button), so it gets Xbox glyphs with X and Y1803-// traded.1804-GLYPHS.xboxswap = GLYPHS.xbox.map((g, i) => GLYPHS.xbox[i === 2 ? 3 : i === 3 ? 2 : i]);1805-1806-// PAD_LABELS name the glyph sets, for the Settings choice that overrides1807-// what the pad's id suggests.1808-const PAD_LABELS = [['', 'Auto'], ['xbox', 'Xbox'], ['xboxswap', 'Xbox, X and Y swapped'], ['switch', 'Nintendo'], ['playstation', 'PlayStation'], ['steamdeck', 'Steam Deck']];1809-const padLabelsKey = 'airwaves.padLabels';1810-1811-function setPadFamily(id) {1812- prompts.detected = /054c|sony|playstation|dualsense|dualshock/i.test(id) ? 'playstation'1813- : /057e|nintendo|switch/i.test(id) ? 'switch'1814- : /2dc8|8bitdo/i.test(id) ? 'xboxswap'1815- : /28de|valve|steam deck/i.test(id) ? 'steamdeck' : 'xbox';1816- applyPadFamily();1817-}1818-1819-function applyPadFamily() {1820- const forced = localStorage.getItem(padLabelsKey) || '';1821- const family = GLYPHS[forced] ? forced : prompts.detected;1822- if (family === prompts.family) return;1823- prompts.family = family;1824- renderHints();1825-}1826-1827-function setPrompts(pad) {1828- if (pad === prompts.pad) return;1829- prompts.pad = pad;1830- document.body.classList.toggle('pad-input', pad);1831- renderHints();1832-}1833-1834-// glyph is the controller button for key, or '' when there is none.1835-function glyph(key, label) {1836- const i = GLYPH_KEYS.indexOf(key);1837- if (i < 0) return '';1838- const file = GLYPHS[prompts.family][i];1839- return `<img class="glyph" src="vendor/kenney-prompts/${file}${/press|dpad_none/.test(file) ? '' : '_outline'}.svg" alt="${esc(label)}">`;1840-}1841-1842-// The keys of the Android TV app's remote, as hints name them.1843-const REMOTE_KEYS = { Enter: 'OK', Escape: 'Back', ' ': 'Play/Pause', Arrows: 'Arrows', UpDown: 'Up, Down', LeftRight: 'Left, Right' };1844-1845-// keyHint shows how to press key: its button after a controller was used,1846-// else its label (on the Android TV app, the remote's); null when the1847-// controller or the remote has no such button.1848-const keyHint = (key, label) => {1849- if (prompts.pad) return glyph(key, label) || null;1850- if (platform === 'tv') return REMOTE_KEYS[key] ? esc(REMOTE_KEYS[key]) : null;1851- return esc(label);1852-};1853-1854-// hints joins [key, label, what] into a line of hints, leaving out what the1855-// controller can't do.1856-const hints = (items) => items.map(([key, label, what]) => {1857- const k = keyHint(key, label);1858- return k == null ? '' : `${k} ${what}`;1859-}).filter(Boolean).join(' | ');1860-1861-// renderHints redraws whatever shows keys, after the input changed.1862-function renderHints() {1863- for (const k of $$('#dock kbd')) {1864- const h = keyHint(k.dataset.key, k.dataset.label);1865- k.innerHTML = h || '';1866- k.hidden = h == null;1867- }1868- renderSettings();1869- if (!state.report) return;1870- if (state.view === 'guide') { guideShown = []; renderGuide(); }1871- if (state.view === 'recordings') renderRecordings();1872- renderBanner();1873-}1874-1875-// ---------- guide ----------1876-const FILTERS = [1877- ['all', 'All'],1878- ['favorites', 'Favorites'],1879- ['nextgen', 'NextGen'],1880- ['sports', 'Sports'],1881- ['movie', 'Movies'],1882- ['news', 'News'],1883- ['family', 'Family'],1884-];1885-1886-// guideFilters adds a filter for each category of the lineup's own channels1887-// that none of FILTERS covers ("Kids", "Gaming"), in the order categories1888-// are listed on the admin page. Not Weather: the weather channel leads the1889-// guide anyway. NextGen only with a tuner that takes in ATSC 3.0.1890-function guideFilters() {1891- const base = FILTERS.filter(([k]) => k !== 'nextgen' || nextGen());1892- const keys = new Set([...base.map(([k]) => k), 'weather']);1893- const extra = [];1894- for (const category of Object.keys(CATEGORY_GENRES)) {1895- const k = categoryGenres(category)[0];1896- if (keys.has(k) || !state.lineup.some((c) => c.own && c.category === category)) continue;1897- keys.add(k);1898- extra.push([k, category]);1899- }1900- return [...base, ...extra];1901-}1902-1903-function guideChannels() {1904- const lo = state.gStart;1905- const hi = state.gStart + state.gSpan * MIN;1906- switch (state.guideFilter) {1907- case 'all': return state.lineup;1908- case 'favorites': return state.lineup.filter(isFav);1909- case 'nextgen': return state.lineup.filter((c) => c.atsc3);1910- default:1911- return state.lineup.filter((c) => programsFor(c).some((p) => p._e > lo && p._s < hi && (p.genres || []).includes(state.guideFilter)));1912- }1913-}1914-1915-function openGuide() {1916- const now = Date.now();1917- state.gStart = floor30(now);1918- state.gTime = now;1919- guideZone('grid');1920- const list = guideChannels();1921- state.gRow = Math.max(0, list.findIndex((c) => state.current && c.key === state.current.key));1922- renderGuide();1923- scrollRowIntoView(true);1924-}1925-1926-// The guide's parts take the focus in turn (data-zone): the grid; the1927-// filters above it, Up from its first row; and the selected program's1928-// actions in the detail (Watch, Record, Series, New only, Favorite, Hide),1929-// which OK on a program to come brings up, as holding OK or I does on any1930-// (Kodi's and Android TV's long press for more). OK on what's on now1931-// watches it, as in the Live TV app's guide.1932-const guideUI = { zone: 'grid', act: '' };1933-1934-function guideZone(zone, act = '') {1935- guideUI.zone = zone;1936- $('#guide').dataset.zone = zone;1937- if (act) guideUI.act = act;1938- if (state.dock >= 0 || state.view !== 'guide') return;1939- if (zone === 'grid') return blurIn($('#guide'));1940- if (zone === 'filters') {1941- const btns = $$('.g-filters button');1942- focusEl(btns.find((b) => b.dataset.filter === state.guideFilter) || btns[0]);1943- return;1944- }1945- const acts = guideActs();1946- if (!acts.length) return guideZone('grid');1947- focusEl(acts.find((b) => b.dataset.act === guideUI.act) || acts[0]);1948-}1949-1950-const guideActs = () => $$('#guide .gd-actions .act').filter(isShown);1951-1952-// guideRefocus puts the focus back on the filters or actions after they1953-// were drawn again.1954-function guideRefocus() {1955- if (state.view !== 'guide' || state.dock >= 0 || guideUI.zone === 'grid') return;1956- if (!$('#guide').contains(document.activeElement)) guideZone(guideUI.zone);1957-}1958-1959-function guideOpenActions() {1960- if (state.view === 'guide' && guideChannels()[state.gRow]) guideZone('actions', '-');1961-}1962-1963-function wireGuide() {1964- const actions = f($('#guide'), 'actions');1965- actions.addEventListener('click', (e) => {1966- const b = e.target.closest('[data-act]');1967- if (!b) return;1968- const list = guideChannels();1969- const ch = list[state.gRow];1970- switch (b.dataset.act) {1971- case 'watch': return watchChannel(ch);1972- case 'record': return openRecordPick(ch, selectedProgram(list));1973- case 'once': state.recPick = ''; return recordSelected('once');1974- case 'series': state.recPick = ''; return recordSelected('series', false);1975- case 'new': state.recPick = ''; return recordSelected('series', true);1976- case 'fav': return toggleFavorite(ch);1977- case 'hide': guideZone('grid'); return hideChannel(ch);1978- default: return null;1979- }1980- });1981- actions.addEventListener('focusin', (e) => {1982- const b = e.target.closest('[data-act]');1983- if (b) guideUI.act = b.dataset.act;1984- });1985- const rows = $('.g-rows');1986- rows.addEventListener('click', (e) => {1987- const cell = e.target.closest('[data-row]');1988- if (!cell) return;1989- guideZone('grid');1990- state.gRow = Number(cell.dataset.row);1991- if (cell.dataset.t) state.gTime = Number(cell.dataset.t);1992- renderGuide();1993- });1994- rows.addEventListener('dblclick', (e) => {1995- const cell = e.target.closest('[data-row]');1996- if (cell) guideActivate();1997- });1998-}1999-2000-function guideKey(e) {2001- if (guideUI.zone === 'filters' && guideFiltersKey(e)) return;2002- if (guideUI.zone === 'actions' && guideActionsKey(e)) return;2003- const list = guideChannels();2004- const sel = selectedProgram(list);2005- switch (e.key) {2006- // Up and Down wrap round the channels, so the last ones are a press2007- // away. The dock is Left from the earliest program, and the filters2008- // Down from the dock.2009- case 'ArrowUp': state.gRow = list.length ? (state.gRow - 1 + list.length) % list.length : 0; break;2010- case 'ArrowDown': state.gRow = list.length ? (state.gRow + 1) % list.length : 0; break;2011- case 'PageUp': state.gRow = state.gRow === 0 ? list.length - 1 : Math.max(0, state.gRow - 8); break;2012- case 'PageDown': state.gRow = state.gRow === list.length - 1 ? 0 : Math.min(list.length - 1, state.gRow + 8); break;2013- case 'ArrowRight':2014- state.gTime = sel ? sel._e : state.gTime + 30 * MIN;2015- while (state.gTime >= state.gStart + (state.gSpan - 30) * MIN) state.gStart += 30 * MIN;2016- break;2017- case 'ArrowLeft': {2018- const min = floor30(Date.now()) - 30 * MIN;2019- // From the earliest program, Left moves on to the dock.2020- if (!sel || sel._s <= min) { e.preventDefault(); return openDock(); }2021- state.gTime = Math.max(min, (sel ? sel._s : state.gTime) - 1);2022- while (state.gTime < state.gStart && state.gStart > min) state.gStart -= 30 * MIN;2023- break;2024- }2025- case 'Home': state.gStart = floor30(Date.now()); state.gTime = Date.now(); break;2026- case 'Enter':2027- e.preventDefault();2028- // A key's press acts on its keyup, a hold brings up the actions; a2029- // controller's A, at once.2030- if (e.isTrusted) return holdStart(() => { if (state.view === 'guide' && guideUI.zone === 'grid') guideActivate(); }, guideOpenActions);2031- return guideActivate();2032- case 'i': case 'I': e.preventDefault(); return guideOpenActions();2033- case 'r': e.preventDefault(); recordSelected('once'); return;2034- case 'F': e.preventDefault(); toggleFavorite(list[state.gRow]); return;2035- case 'h': case 'H': e.preventDefault(); hideChannel(list[state.gRow]); return;2036- case 'R': e.preventDefault(); recordSelected('series', false); return;2037- case 'n': case 'N': e.preventDefault(); recordSelected('series', true); return;2038- default: {2039- if (e.key === ']' || e.key === '[') {2040- const filters = guideFilters();2041- const i = filters.findIndex(([k]) => k === state.guideFilter);2042- state.guideFilter = filters[(i + (e.key === ']' ? 1 : filters.length - 1)) % filters.length][0];2043- state.gRow = 0;2044- break;2045- }2046- return;2047- }2048- }2049- e.preventDefault();2050- renderGuide();2051- scrollRowIntoView();2052-}2053-2054-// guideFiltersKey: Left and Right pick a filter (as a tab does), Down or OK2055-// goes to the grid, Up (or Left from the first) to the dock.2056-function guideFiltersKey(e) {2057- const btns = $$('.g-filters button');2058- const i = Math.max(0, btns.findIndex((b) => b.dataset.filter === state.guideFilter));2059- switch (e.key) {2060- case 'ArrowLeft': case 'ArrowRight': {2061- const j = i + (e.key === 'ArrowLeft' ? -1 : 1);2062- if (j < 0) { openDock(); break; }2063- if (j >= btns.length) break;2064- state.guideFilter = btns[j].dataset.filter;2065- state.gRow = 0;2066- renderGuide();2067- $('.g-rows').scrollTop = 0;2068- break;2069- }2070- case 'ArrowUp': openDock(); break;2071- case 'ArrowDown': case 'Enter': guideZone('grid'); break;2072- default: return false;2073- }2074- e.preventDefault();2075- return true;2076-}2077-2078-// guideActionsKey: Left and Right along the actions, OK presses, Down (or2079-// Back) goes back to the grid, Up to the dock.2080-function guideActionsKey(e) {2081- const acts = guideActs();2082- const el = document.activeElement;2083- switch (e.key) {2084- case 'ArrowLeft': case 'ArrowRight': focusEl(neighbor(acts, el, e.key === 'ArrowLeft' ? -1 : 1) || (acts.includes(el) ? null : acts[0])); break;2085- case 'ArrowUp': openDock(); break;2086- case 'ArrowDown': guideZone('grid'); break;2087- case 'Enter':2088- if (acts.includes(el)) el.click(); else guideZone('actions');2089- break;2090- default: return false;2091- }2092- e.preventDefault();2093- return true;2094-}2095-2096-// guideActivate is OK on a program: what's on now (or was, a little2097-// before) is watched on its channel, and a program to come shows what can2098-// be done with it.2099-function guideActivate() {2100- const list = guideChannels();2101- const ch = list[state.gRow];2102- if (!ch) return;2103- const p = selectedProgram(list);2104- if (!p || p._s <= Date.now()) watchChannel(ch);2105- else guideOpenActions();2106-}2107-2108-// watchChannel goes back to TV on a channel. Choosing what's already2109-// playing goes back to it without tuning again (and losing the rewind2110-// buffer); a failed channel retries.2111-function watchChannel(ch) {2112- if (!ch) return;2113- const playing = state.current && state.current.key === ch.key && !state.recording && $('#nosignal').hidden;2114- if (!playing) tune(ch);2115- setView('tv');2116- if (playing) showBanner();2117-}2118-2119-function selectedProgram(list) {2120- const ch = list[state.gRow];2121- if (!ch) return null;2122- const t = Math.max(state.gTime, state.gStart);2123- return airingAt(ch, t) || null;2124-}2125-2126-function scrollRowIntoView(center) {2127- const rows = $('.g-rows');2128- const rowH = 58;2129- const top = state.gRow * rowH;2130- if (center) rows.scrollTop = top - rows.clientHeight / 2 + rowH;2131- else if (top < rows.scrollTop) rows.scrollTop = top;2132- else if (top + rowH > rows.scrollTop + rows.clientHeight) rows.scrollTop = top + rowH - rows.clientHeight;2133-}2134-2135-// guideShown is what the guide's rows were last built from. They are built2136-// again only when that changes; moving the selection moves two classes, as2137-// rebuilding thousands of cells on every key is slow on a TV box.2138-let guideShown = [];2139-2140-function renderGuide() {2141- if (!state.report) return;2142- const g = $('#guide');2143- const list = guideChannels();2144- state.gRow = Math.min(state.gRow, Math.max(0, list.length - 1));2145- const rows = $('.g-rows');2146- const lo = state.gStart;2147- const now = Date.now();2148- const shown = [state.guideFilter, lo, rows.clientWidth, Math.floor(now / MIN), state.lineup, state.guide, state.dvrKeys, state.settings, state.current && state.current.key, state.wx, state.sigStamp];2149- if (shown.every((v, i) => v === guideShown[i])) return moveGuideSelection(list, lo);2150- guideShown = shown;2151- const chw = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--chw')) || 260;2152- const width = Math.max(300, rows.clientWidth - chw);2153- const ppm = width / state.gSpan;2154- const hi = lo + state.gSpan * MIN;2155-2156- // filters2157- const filters = guideFilters();2158- if (!filters.some(([k]) => k === state.guideFilter)) state.guideFilter = 'all';2159- $('.g-filters').innerHTML = filters.map(([k, label]) => {2160- const saved = state.guideFilter;2161- state.guideFilter = k;2162- const n = guideChannels().length;2163- state.guideFilter = saved;2164- return `<button type="button" role="tab" aria-selected="${k === state.guideFilter}" data-filter="${k}" class="fx${k === state.guideFilter ? ' on' : ''}">${label}<span class="n">${n}</span></button>`;2165- }).join('');2166- $$('.g-filters button').forEach((b) => b.addEventListener('click', () => {2167- state.guideFilter = b.dataset.filter;2168- state.gRow = 0;2169- renderGuide();2170- }));2171-2172- // time header2173- const d = new Date(lo);2174- const today = new Date(now).toDateString() === d.toDateString();2175- f(g, 'day').textContent = today ? 'Today' : d.toLocaleDateString([], { weekday: 'long' });2176- let ticks = '';2177- for (let t = lo; t < hi; t += 30 * MIN) ticks += `<span style="left:${(t - lo) / MIN * ppm}px">${clock(t)}</span>`;2178- $('.g-ticks').innerHTML = ticks;2179-2180- // rows2181- const sel = selectedProgram(list);2182- let html = '';2183- list.forEach((ch, r) => {2184- // A channel's measured signal shows only when it explains a bad picture.2185- const sig = ch.own ? null : sigOf(sigFor(ch));2186- const bad = sig && trouble(sig);2187- const cls = ['g-row', r === state.gRow ? 'sel' : '', state.current && ch.key === state.current.key ? 'cur' : ''].join(' ');2188- html += `<div class="${cls}"><div class="g-ch" data-row="${r}">2189- <div class="num${ch.number.length > 4 ? ' long' : ''}">${esc(ch.number)}</div>2190- <div class="who"><div class="net">${isFav(ch) ? '<span class="fav">★</span>' : ''}${esc(ch.network || displayCall(ch))}</div><div class="call"${bad ? ` title="${esc(`${sig.label}: ${sigDetail(sig)}`)}"` : ''}>${bad ? meter(sig) : ''}<span>${esc(displayCall(ch))}</span></div></div>2191- ${ch.logoDark ? `<div class="logo own" style="background-image:url('${esc(ch.logoDark)}')"></div>`2192- : ch.logo ? `<div class="logo${ch.own ? ' own' : ''}" style="background-image:url('${esc(ch.logo)}')"></div>` : '<div></div>'}2193- </div><div class="g-progs">`;2194- const progs = programsFor(ch).filter((p) => p._e > lo && p._s < hi);2195- if (!progs.length) {2196- const label = ch.weather ? 'Local forecast, around the clock' : ch.custom ? ch.description || 'Nothing scheduled yet' : 'No listings';2197- html += `<div class="g-cell empty${r === state.gRow ? ' sel' : ''}" data-row="${r}" style="left:2px;width:${width - 4}px"><div class="t">${label}</div></div>`;2198- }2199- for (const p of progs) {2200- const left = Math.max(0, (p._s - lo) / MIN * ppm);2201- const right = Math.min(width, (p._e - lo) / MIN * ppm);2202- const w = Math.max(0, right - left - 3);2203- const isSel = r === state.gRow && sel === p;2204- const rec = state.dvrKeys.get(airingKey(ch, p));2205- const recCls = rec ? (rec.status === 'unavailable' || rec.status === 'conflict' ? 'rec rec-x' : rec.status === 'recording' ? 'rec rec-on' : 'rec') : '';2206- const c = ['g-cell', p._e <= now ? 'past' : '', p._s <= now && now < p._e ? 'now' : '', isSel ? 'sel' : '', recCls].join(' ');2207- const tag = (p.flags || []).includes('Live') ? '<span class="tag">LIVE </span>' : (p.flags || []).includes('New') ? '<span class="tag">NEW </span>' : '';2208- const sub = p.episodeTitle || `${clock(p._s)} to ${clock(p._e)}`;2209- html += `<div class="${c}" data-row="${r}" data-t="${Math.max(p._s, lo)}" style="left:${left + 2}px;width:${w}px"><div class="t">${tag}${esc(p.title)}</div>${w > 90 ? `<div class="s">${esc(sub)}</div>` : ''}</div>`;2210- }2211- html += '</div></div>';2212- });2213- const keep = rows.scrollTop;2214- rows.innerHTML = html || '<div style="padding:28px;color:var(--muted)">No channels match this filter.</div>';2215- rows.scrollTop = keep;2216-2217- // now line2218- const nl = $('.g-now');2219- if (now >= lo && now < hi) {2220- nl.style.display = '';2221- nl.style.left = `${chw + (now - lo) / MIN * ppm}px`;2222- } else nl.style.display = 'none';2223-2224- renderGuideDetail(list[state.gRow], sel);2225- f(g, 'status').innerHTML = `${list.length} channels | ${guideStatusHints()}`;2226- guideRefocus();2227-}2228-2229-// guideStatusHints names the guide's keys beside its filters.2230-function guideStatusHints() {2231- if (onRemote()) return 'Filters: Left to the top bar, then Down';2232- return hints([['i', 'I', 'more'], ['', 'Shift F', 'favorite'], ['h', 'H', 'hide'], ['[', '[ ]', 'filter']]);2233-}2234-2235-// moveGuideSelection marks the selected row and program in rows already2236-// built, as renderGuide would.2237-function moveGuideSelection(list, lo) {2238- const rows = $('.g-rows');2239- const sel = selectedProgram(list);2240- for (const el of rows.querySelectorAll('.sel')) el.classList.remove('sel');2241- const row = list.length ? rows.children[state.gRow] : null;2242- if (row) {2243- row.classList.add('sel');2244- const cell = sel ? row.querySelector(`.g-cell[data-t="${Math.max(sel._s, lo)}"]`) : row.querySelector('.g-cell.empty');2245- if (cell) cell.classList.add('sel');2246- }2247- renderGuideDetail(list[state.gRow], sel);2248-}2249-2250-function humanMinutes(m) {2251- if (m < 60) return `${m} min`;2252- const h = Math.floor(m / 60);2253- return m % 60 ? `${h} h ${m % 60} min` : `${h} h`;2254-}2255-2256-// renderGuideActions draws the selected program's actions: Watch first for2257-// what's on now, the recording ones where recording applies (the2258-// antenna's channels, on a server with a DVR), then the channel's.2259-function renderGuideActions(ch, p) {2260- const box = f($('#guide'), 'actions');2261- if (!ch) { box.innerHTML = ''; return; }2262- // Laid out, it may need less shown beside it.2263- requestAnimationFrame(fitGuideDetail);2264- const now = Date.now();2265- const airing = !p || p._s <= now;2266- const act = (name, label, cls = '') => `<button type="button" class="act fx${cls ? ` ${cls}` : ''}" data-act="${name}">${label}</button>`;2267- const parts = [];2268- if (airing) parts.push(act('watch', !p || now < p._e ? 'Watch' : `Watch ${esc(ch.number)}`));2269- if (state.info.dvr && p && p._e > now && !ch.weather && !ch.custom) {2270- const it = state.dvrKeys.get(airingKey(ch, p));2271- const rule = seriesRuleFor(ch, p);2272- if (it) {2273- const label = { recording: 'Recording now', unavailable: 'Channel not available', conflict: 'Conflict: every tuner records then' }[it.status] || 'Recording scheduled';2274- parts.push(`<span class="chip ${it.status === 'unavailable' || it.status === 'conflict' ? 'warn' : 'live'}">${label}</span>`);2275- parts.push(act('once', it.status === 'recording' ? 'Stop recording' : 'Cancel'));2276- } else if (!rule && p.seriesId) {2277- // One Record, which opens to this airing, every episode or new ones;2278- // another program closes it.2279- if (state.recPick !== airingKey(ch, p)) state.recPick = '';2280- parts.push(state.recPick === airingKey(ch, p)2281- ? act('once', '<i></i>This one', 'rec') + act('series', 'Every episode') + act('new', 'New only')2282- : act('record', '<i></i>Record', 'rec'));2283- } else {2284- parts.push(act('once', '<i></i>Record', 'rec'));2285- }2286- if (rule) {2287- parts.push(`<span class="chip new">Series${rule.newOnly ? ', new only' : ''}</span>${act('series', 'Stop series')}`);2288- }2289- }2290- if (!airing) parts.push(act('watch', `Watch ${esc(ch.number)}`));2291- parts.push(act('fav', isFav(ch) ? 'Unfavorite' : 'Favorite'));2292- parts.push(act('hide', 'Hide channel'));2293- box.innerHTML = parts.join('');2294- guideRefocus();2295-}2296-2297-// fitGuideDetail leaves out what of the program's details doesn't fit2298-// above the grid at this window and interface size: its cast first, then2299-// its description, its tags, its episode line and the hint. The title and the2300-// actions always show, the actions on one row that scrolls sideways when2301-// even that isn't enough.2302-function fitGuideDetail() {2303- const g = $('#guide');2304- const top = $('.g-top', g);2305- const box = $('.g-detail', g);2306- // The picture needs the details to keep a readable width beside it.2307- top.classList.toggle('no-art', top.clientWidth < 1100);2308- const parts = ['cast', 'desc', 'meta', 'ep', 'hint'].map((k) => f(g, k));2309- for (const el of parts) el.classList.remove('gd-gone');2310- box.classList.remove('one-row');2311- const cs = getComputedStyle(top);2312- const room = top.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);2313- for (const el of parts) {2314- if (box.offsetHeight <= room) return;2315- el.classList.add('gd-gone');2316- }2317- if (box.offsetHeight > room) box.classList.add('one-row');2318-}2319-2320-// openRecordPick opens Record into its three choices, the first focused.2321-function openRecordPick(ch, p) {2322- if (!ch || !p) return;2323- state.recPick = airingKey(ch, p);2324- renderGuideActions(ch, p);2325- focusEl(f($('#guide'), 'actions').querySelector('[data-act="once"]'));2326-}2327-2328-// closeRecordPick folds the choices back into Record, focused.2329-function closeRecordPick() {2330- state.recPick = '';2331- const list = guideChannels();2332- renderGuideActions(list[state.gRow], selectedProgram(list));2333- const rec = f($('#guide'), 'actions').querySelector('[data-act="record"]');2334- if (rec && guideUI.zone === 'actions') focusEl(rec);2335-}2336-2337-function recordSelected(kind, newOnly) {2338- const list = guideChannels();2339- return recordAiring(list[state.gRow], selectedProgram(list), kind, newOnly);2340-}2341-2342-// recordNow records what is on the channel being watched.2343-function recordNow(kind, newOnly) {2344- const ch = state.current;2345- if (state.recording || !ch) return;2346- const p = airingAt(ch, Date.now());2347- if (!p) return toast('No listing to record on this channel');2348- return recordAiring(ch, p, kind, newOnly).then(renderBanner);2349-}2350-2351-async function recordAiring(ch, p, kind, newOnly) {2352- if (!state.info.dvr) return toast('Recording is not available on this server');2353- if (!ch || !p) return;2354- if (ch.weather) return toast('The weather channel is always on; nothing to record');2355- if (ch.custom) return toast(`${ch.name} is made by your server; nothing to record`);2356- if (p._e <= Date.now()) return toast('That has already aired');2357- const it = state.dvrKeys.get(airingKey(ch, p));2358- const rule = seriesRuleFor(ch, p);2359- try {2360- if (kind === 'once' && it && it.status === 'recording') {2361- await api().StopRecording(it.id);2362- toast(`Stopped recording ${p.title}; what was recorded is in the library`);2363- } else if (kind === 'once' && it) {2364- await api().DeleteRecording(it.id);2365- toast(`Won't record ${p.title}`);2366- } else if (kind === 'series' && rule) {2367- await api().DeleteRule(rule.id);2368- toast(`Stopped the series recording of ${p.title}`);2369- } else if (kind === 'series' && !p.seriesId) {2370- return toast(`${p.title} has no series information. Press R to record this airing.`);2371- } else {2372- await api().Record({ kind, channel: ch.number, callSign: ch.callSign, start: p.start, newOnly: !!newOnly });2373- const aired = Math.round((Date.now() - p._s) / MIN);2374- toast(kind === 'series'2375- ? `Recording ${newOnly ? 'new episodes' : 'every episode'} of ${p.title} on ${ch.number}`2376- : aired >= 1 ? `Recording ${p.title} from now; the first ${aired} minute${aired > 1 ? 's' : ''} already aired`2377- : `Recording ${p.title} at ${clock(p._s)} on ${ch.number}`);2378- }2379- } catch (e) {2380- toast(String(e && e.message ? e.message : e), 6000);2381- }2382- await loadDVR();2383-}2384-2385-function renderGuideDetail(ch, p) {2386- const g = $('#guide');2387- if (!ch) {2388- // No channels under this filter.2389- for (const k of ['kicker', 'title', 'ep', 'meta', 'cast', 'desc', 'actions', 'hint']) f(g, k).textContent = '';2390- f(g, 'art').classList.remove('has');2391- return;2392- }2393- const now = Date.now();2394- f(g, 'kicker').innerHTML = `<span>${esc(ch.number)} ${esc(ch.own ? ch.call : displayCall(ch))}</span><span style="color:var(--muted)">${p ? `${clock(p._s)} to ${clock(p._e)}` : ''}</span>`;2395- f(g, 'title').textContent = p ? p.title : ch.own ? ch.name : 'No listings';2396- f(g, 'ep').textContent = episodeLine(p);2397- // An own channel's category is among its programs' genres; with nothing2398- // listed it shows on its own, with the channel's description.2399- const genres = p ? p.genres || [] : ch.own && ch.category !== 'Other' ? [ch.category] : [];2400- f(g, 'meta').innerHTML = chipsFor(p, ch) + genres.map((x) => `<span class="chip">${esc(x)}</span>`).join('');2401- f(g, 'cast').textContent = castLine(p);2402- f(g, 'desc').textContent = p ? p.description || '' : ch.description || '';2403- const live = !p || p._s <= now;2404- const ok = keyHint('Enter', 'Enter');2405- const when = !live ? `Starts in ${humanMinutes(Math.round((p._s - now) / MIN))} | ${ok} for more` : p && p._e <= now ? `Ended ${clock(p._e)} | ${ok} to watch ${esc(ch.number)}` : `${ok} to watch`;2406- const more = !live ? '' : prompts.pad ? hints([['i', 'I', 'for more']]) : `hold ${ok} for more`;2407- const rec = state.info.dvr && p && p._e > now && !ch.weather && !ch.custom && onKeys() ? ' | R record, Shift R series, N new only' : '';2408- f(g, 'hint').innerHTML = `${when}${more ? `, ${more}` : ''}${rec}`;2409- renderGuideActions(ch, p);2410- const art = f(g, 'art');2411- if (p && p.image) { art.style.backgroundImage = `url('${imageURL(p.image)}')`; art.classList.add('has'); } else { art.style.backgroundImage = ''; art.classList.remove('has'); }2412-}2413-2414-// ---------- reception ----------2415-// Reception is what the tuners measured, by RF channel, with what the FCC2416-// licenses on each; nothing in it is estimated. The rail has the tuner and2417-// Measure now, which has the server read every RF channel worth reading2418-// on a tuner nobody is using, the ones its scan found first. The table2419-// lists the RF channels, those found first; the radar places their2420-// transmitters, and the detail under it is the picked RF channel's2421-// readings over time and the stations on it.2422-2423-// applySignal takes in what the server measured: a snapshot's antenna2424-// section, or /api/signal.2425-function applySignal(sig) {2426- const was = state.antenna && state.antenna.sweep;2427- const wasScanning = !!(state.antenna && state.antenna.tuner.scanning);2428- state.antenna = { tuner: sig.tuner || {}, muxes: sig.muxes || [], sweep: sig.sweep || {} };2429- state.byRF = new Map(state.antenna.muxes.map((m) => [m.rf, m]));2430- const by = new Map((sig.channels || []).map((c) => [c.number, c]));2431- for (const ch of state.antennaChans) {2432- const c = by.get(ch.number);2433- if (c) [ch.signal, ch.recent] = [c.signal, c.recent];2434- }2435- state.sigStamp++;2436- const sw = state.antenna.sweep;2437- if (was && was.running && !sw.running && receptionShown()) {2438- toast(sw.note ? `Measure now ${sw.note}` : `Measured ${sw.done} RF channels`, 4000);2439- }2440- // A channel scan just finished: its channels are the lineup now.2441- if (wasScanning && !state.antenna.tuner.scanning) {2442- scan(false).then(() => {2443- renderAll();2444- toast(`The scan found ${state.antennaChans.length} channels`, 4000);2445- }).catch((e) => log('warn', e));2446- }2447-}2448-2449-let signalAt = 0;2450-let signalBusy = false;2451-2452-// loadSignal fetches what was measured and redraws what shows it.2453-async function loadSignal() {2454- if (!state.info || state.info.antenna === false || signalBusy || !api().Signal) return;2455- signalBusy = true;2456- try {2457- applySignal(await api().Signal());2458- } catch (e) {2459- log('warn', `signal: ${e}`);2460- return;2461- } finally {2462- signalBusy = false;2463- signalAt = Date.now();2464- }2465- if (receptionShown()) renderAntenna();2466- if (state.view === 'guide') renderGuide();2467- if (state.view === 'tv' && $('#banner').classList.contains('show')) renderBanner();2468-}2469-2470-// signalTick reads the signal again while it's on screen: every 2 s while2471-// Measure now or a channel scan runs, every 10 s in Reception or with an2472-// antenna channel's banner up, every 30 s in the guide.2473-function signalTick() {2474- if (!state.info || state.info.antenna === false || document.hidden) return;2475- const running = !!(state.antenna && (state.antenna.sweep.running || state.antenna.tuner.scanning));2476- const banner = state.view === 'tv' && state.current && !state.current.own && $('#banner').classList.contains('show');2477- const every = running ? 2000 : receptionShown() || banner ? 10_000 : state.view === 'guide' ? 30_000 : 0;2478- if (every && Date.now() - signalAt >= every - 100) loadSignal();2479-}2480-2481-// measureNow starts Measure now on the server.2482-async function measureNow() {2483- const sw = state.antenna && state.antenna.sweep;2484- if (sw && sw.running) return toast(`Measuring: ${sw.done} of ${sw.total} RF channels`, 2000);2485- try {2486- const st = await api().Measure();2487- if (state.antenna) state.antenna.sweep = st;2488- signalAt = 0;2489- renderMeasure();2490- } catch (e) {2491- toast(String(e && e.message ? e.message : e), 6000);2492- }2493- return undefined;2494-}2495-2496-// scanChannels has the tuner scan for channels again, on a second press2497-// within a few seconds: it takes every tuner for a few minutes.2498-let scanArmed = 0;2499-async function scanChannels() {2500- const t = (state.antenna && state.antenna.tuner) || {};2501- if (t.scanning) return toast('The tuner is scanning for channels', 2000);2502- if (Date.now() - scanArmed > 4000) {2503- scanArmed = Date.now();2504- renderScan();2505- setTimeout(renderScan, 4100);2506- return undefined;2507- }2508- scanArmed = 0;2509- try {2510- const ti = await api().ScanChannels();2511- if (state.antenna) state.antenna.tuner = ti;2512- signalAt = 0;2513- renderTunerCard();2514- renderScan();2515- } catch (e) {2516- toast(String(e && e.message ? e.message : e), 6000);2517- }2518- return undefined;2519-}2520-2521-// renderScan shows the channel scan's button: what it does, and that it2522-// asks twice.2523-function renderScan() {2524- const a = $('#antenna');2525- const t = (state.antenna && state.antenna.tuner) || {};2526- const btn = f(a, 'scan');2527- btn.classList.toggle('busy', !!t.scanning);2528- btn.textContent = t.scanning ? 'Scanning' : 'Scan for channels';2529- f(a, 'snote').textContent = t.scanning ? 'The tuner is looking for channels; watching and measuring wait until it is done'2530- : Date.now() - scanArmed < 4000 ? 'Press again to scan: it takes every tuner for a few minutes'2531- : 'After moving the antenna, or when channels go missing';2532-}2533-2534-function wireAntenna() {2535- const a = $('#antenna');2536- f(a, 'measure').addEventListener('click', () => measureNow());2537- f(a, 'scan').addEventListener('click', () => scanChannels());2538- $('.a-table tbody').addEventListener('click', (e) => {2539- const tr = e.target.closest('tr[data-rf]');2540- if (tr) pickRF(Number(tr.dataset.rf));2541- });2542- $('.radar').addEventListener('click', (e) => {2543- const dot = e.target.closest('[data-rf]');2544- if (dot) pickRF(Number(dot.dataset.rf));2545- });2546-}2547-2548-// Reception's parts take the focus in turn (data-zone): the RF channels'2549-// table, and above it a row of the tuner's card, Measure now and Scan. OK2550-// on an RF channel with channels watches the first. Left from either goes2551-// back to Settings' tabs.2552-const antUI = { zone: 'table', key: 'measure' };2553-2554-const antHead = () => $$('#antenna .a-head .fx').filter(isShown);2555-2556-function antZone(zone, key = '') {2557- antUI.zone = zone;2558- $('#antenna').dataset.zone = zone;2559- if (key) antUI.key = key;2560- if (state.dock >= 0 || !receptionShown() || settingsUI.zone !== 'list') return;2561- renderRFDetail(); // its OK hint is the table's2562- if (zone === 'table') {2563- blurIn($('#antenna'));2564- scrollRF();2565- return;2566- }2567- const items = antHead();2568- focusEl(items.find((el) => el.dataset.act === antUI.key) || items[0]);2569-}2570-2571-function antRefocus() {2572- if (!receptionShown() || state.dock >= 0 || settingsUI.zone !== 'list' || antUI.zone !== 'head') return;2573- const el = document.activeElement;2574- if (!$('#antenna').contains(el) || !isShown(el)) antZone('head');2575-}2576-2577-// antennaKey handles a key in Reception, reporting whether it did.2578-function antennaKey(e) {2579- if (antUI.zone === 'head') {2580- const items = antHead();2581- const el = document.activeElement;2582- const i = items.indexOf(el);2583- switch (e.key) {2584- case 'ArrowUp': openDock(); break;2585- case 'ArrowLeft': if (i > 0) focusEl(items[i - 1]); else settingsZone('tabs'); break;2586- case 'ArrowRight': if (i < 0) antZone('head'); else if (i < items.length - 1) focusEl(items[i + 1]); break;2587- case 'ArrowDown': if (antRows().length) antZone('table'); break;2588- case 'Enter':2589- if (el && el.dataset.act === 'measure') measureNow();2590- else if (el && el.dataset.act === 'scan') scanChannels();2591- else if (i < 0) antZone('head');2592- break;2593- default: return false;2594- }2595- // Where the focus comes back to, from the dock.2596- const now = document.activeElement;2597- if (now && now.dataset.act && $('#antenna').contains(now)) antUI.key = now.dataset.act;2598- e.preventDefault();2599- return true;2600- }2601- const rows = antRows();2602- let i = rows.indexOf(state.aRF);2603- switch (e.key) {2604- case 'ArrowUp':2605- if (i <= 0) { e.preventDefault(); antZone('head'); return true; }2606- i--;2607- break;2608- case 'ArrowDown': i = Math.min(rows.length - 1, i + 1); break;2609- case 'ArrowLeft': e.preventDefault(); settingsZone('tabs'); return true;2610- case 'Enter': {2611- e.preventDefault();2612- const m = state.byRF.get(state.aRF);2613- const ch = m && m.channels.length && findChannel(m.channels[0]);2614- if (ch) watchChannel(ch);2615- return true;2616- }2617- default: return false;2618- }2619- e.preventDefault();2620- pickRF(rows[Math.max(0, i)]);2621- return true;2622-}2623-2624-// antMuxes is the RF channels to list: those the scan found (by RF), then2625-// those a licensed station nearby uses; others hold nothing to see.2626-function antMuxes() {2627- const all = state.antenna ? state.antenna.muxes : [];2628- const found = all.filter(muxFound);2629- const rest = all.filter((m) => !muxFound(m) && m.stations.length);2630- return [...found, ...rest];2631-}2632-const muxFound = (m) => m.channels.length > 0;2633-const antRows = () => antMuxes().map((m) => m.rf);2634-const muxSig = (m) => sigOf(m && { signal: m.signal, recent: m.history && m.history.windows.length ? m.history.windows[m.history.windows.length - 1] : null });2635-2636-// tunerName names the tuner: "HDHomeRun FLEX DUO".2637-const tunerName = (t) => (t && t.name) || 'Tuner';2638-2639-function renderAntenna() {2640- if (!state.report || !state.info || state.info.antenna === false) return;2641- const a = $('#antenna');2642- const muxes = antMuxes();2643- if (!muxes.some((m) => m.rf === state.aRF)) state.aRF = muxes.length ? muxes[0].rf : 0;2644- renderTunerCard();2645- renderMeasure();2646- renderScan();2647- const found = muxes.filter(muxFound);2648- const locked = found.filter((m) => muxSig(m).state === 'lock').length;2649- const chans = state.lineup.filter((c) => !c.own).length;2650- f(a, 'stats').innerHTML = `2651- <div><dt>Channels</dt><dd>${chans}<small>on ${found.length} RF channels the scan found</small></dd></div>2652- ${found.length ? `<div><dt>Locked</dt><dd>${locked}<small>of those ${found.length} at their last reading</small></dd></div>` : ''}`;2653- renderRFTable(muxes);2654- renderRadar(muxes);2655- renderRFDetail();2656- renderNextGen();2657- antRefocus();2658-}2659-2660-function renderTunerCard() {2661- const t = (state.antenna && state.antenna.tuner) || state.info.tuner || {};2662- const n = t.tuners || 0;2663- const use = t.inUse ? `${t.inUse} of ${n} in use` : n ? `${n > 1 ? 'Both' : 'It'} free` : '';2664- f($('#antenna'), 'tuner').innerHTML = `2665- <div class="at-label">Tuner</div>2666- <div class="at-name">${esc(tunerName(t))}</div>2667- <div class="at-line">${n ? `${n} tuner${n > 1 ? 's' : ''}, ${esc((t.standards || []).join(', ') || 'ATSC')}` : 'No tuner'}</div>2668- ${use ? `<div class="at-line">${esc(use)}</div>` : ''}2669- ${t.scanning ? '<div class="at-line at-warn">Scanning for channels</div>' : ''}2670- ${t.reason ? `<div class="at-line at-warn">${esc(t.reason)}</div>` : ''}2671- ${t.model ? `<div class="at-model">${esc(t.model)}</div>` : ''}`;2672-}2673-2674-// renderMeasure shows Measure now's progress on its button: the RF2675-// channel being read, how many are done, the found ones first.2676-function renderMeasure() {2677- const a = $('#antenna');2678- const sw = (state.antenna && state.antenna.sweep) || {};2679- const btn = f(a, 'measure');2680- btn.classList.toggle('busy', !!sw.running);2681- f(a, 'mlabel').textContent = sw.running ? (sw.rf ? `Measuring RF ${sw.rf}` : 'Measuring') : 'Measure now';2682- f(a, 'mbar').style.width = sw.running && sw.total ? `${Math.round((sw.done / sw.total) * 100)}%` : '0';2683- f(a, 'mprog').hidden = !sw.running;2684- let note = 'Reads every RF channel on a free tuner, a few seconds each, the ones the scan found first';2685- if (sw.running) {2686- const found = sw.found || 0;2687- note = sw.done < found2688- ? `${sw.done} of the ${found} found by the scan, then ${sw.total - found} more nearby stations use`2689- : `All ${found} found, now ${sw.done - found} of ${sw.total - found} more nearby stations use`;2690- } else if (sw.finishedAt) {2691- note = `Last measured ${ago(Date.parse(sw.finishedAt))}, ${sw.done} of ${sw.total} RF channels${sw.note ? `: ${sw.note}` : ''}`;2692- }2693- f(a, 'mnote').textContent = note;2694-}2695-2696-function renderRFTable(muxes) {2697- const rows = [];2698- let shownRest = false;2699- for (const m of muxes) {2700- const found = muxFound(m);2701- if (!found && !shownRest) {2702- shownRest = true;2703- rows.push('<tr class="a-sect"><td colspan="7">Not found by the scan: RF channels nearby stations use</td></tr>');2704- }2705- const s = muxSig(m);2706- const st = muxStation(m);2707- const more = m.stations.length > 1 ? `, +${m.stations.length - 1} more` : '';2708- rows.push(`<tr data-rf="${m.rf}" class="${m.rf === state.aRF ? 'sel' : ''}${found ? '' : ' nf'}">2709- <td class="rf"><b>${m.rf}</b><div class="sub">${m.frequencyMhz} MHz ${esc(m.band)}</div></td>2710- <td>${st ? `<div class="call">${esc(st.callSign)}${st.atsc3 ? ' <span class="chip ng">3.0</span>' : ''}</div><div class="sub">${esc(st.city)}, ${st.distanceKm.toFixed(0)} km ${compass(st.bearingDeg)}${more}</div>` : '<div class="sub">No licensed station nearby</div>'}</td>2711- <td class="carries">${compactCarries(m.channels).split(' ').filter(Boolean).map((g) => `<span>${esc(g)}</span>`).join(' ')}</td>2712- <td class="c">${meter(s)}</td>2713- <td class="r mono" style="${s.state === 'off' ? 'color:var(--red)' : ''}">${s.state === 'none' ? '' : s.state === 'off' ? 'No lock' : s.quality || 'Lock'}<div class="sub">${s.strength ? `strength ${s.strength}` : ''}</div></td>2714- <td class="r mono">${s.state === 'none' || s.state === 'off' || s.errors == null ? '' : s.errors >= 1 ? `<span class="errs">${Math.round(s.errors)}/s</span>` : '0'}</td>2715- <td class="r mono">${s.state === 'none' ? '' : esc(agoShort(s.at))}</td>2716- </tr>`);2717- }2718- $('.a-table tbody').innerHTML = rows.join('') || `<tr class="a-sect"><td colspan="7">${esc((state.antenna && state.antenna.tuner.reason) || 'No RF channels yet')}</td></tr>`;2719-}2720-2721-// muxStation is the transmitter an RF channel's channels come from, else2722-// the nearest station licensed on it.2723-function muxStation(m) {2724- const tx = state.antennaChans.find((c) => c.rf === m.rf && c.transmitter);2725- return (tx && m.stations.find((x) => x.callSign === tx.transmitter)) || m.stations[0] || null;2726-}2727-2728-// pickRF picks an RF channel in the table.2729-function pickRF(rf) {2730- state.aRF = rf;2731- $$('.a-table tr[data-rf]').forEach((tr) => tr.classList.toggle('sel', Number(tr.dataset.rf) === rf));2732- scrollRF();2733- renderRFDetail();2734- markRadar();2735-}2736-2737-function scrollRF() {2738- const tr = $(`.a-table tr[data-rf="${state.aRF}"]`);2739- if (tr) tr.scrollIntoView({ block: 'nearest' });2740-}2741-2742-// renderRFDetail is the picked RF channel: its last reading, its readings2743-// over time, its channels and the stations licensed on it.2744-function renderRFDetail() {2745- const box = f($('#antenna'), 'detail');2746- const m = state.byRF.get(state.aRF);2747- if (!m) {2748- box.innerHTML = '';2749- return;2750- }2751- const s = muxSig(m);2752- const scan = muxFound(m) ? "In the tuner's lineup" : 'Not found by the scan';2753- const wins = (m.history && m.history.windows) || [];2754- const bars = wins.map((w) => {2755- const q = w.qualityPct ? w.qualityPct.avg : 0;2756- const lo = w.qualityPct ? w.qualityPct.min : 0;2757- const st = w.lockedPct >= 100 ? 'lock' : w.lockedPct > 0 ? 'part' : 'off';2758- const tip = `${new Date(w.from).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}: ${w.samples} reading${w.samples > 1 ? 's' : ''}${w.source === 'active' ? ' while watched' : ''}, locked ${Math.round(w.lockedPct)}%${w.qualityPct ? `, quality ${w.qualityPct.min} to ${w.qualityPct.max}%` : ''}`;2759- return `<i class="h-${st}" style="--h:${Math.max(4, q)}%;--lo:${lo}%" title="${esc(tip)}"></i>`;2760- }).join('');2761- const chans = m.channels.map((n) => {2762- const c = state.antennaChans.find((x) => x.number === n);2763- return `<span><b>${esc(n)}</b> ${esc(c ? displayCall(c) : '')}${c && c.network ? ` ${esc(c.network)}` : ''}</span>`;2764- }).join('');2765- const stations = m.stations.map((x) => `<li><b>${esc(x.callSign)}</b>${x.virtualChannel ? ` (${x.virtualChannel})` : ''} ${esc(x.city)}, ${x.distanceKm.toFixed(0)} km ${compass(x.bearingDeg)}, ${x.erpKw >= 10 ? x.erpKw.toFixed(0) : x.erpKw.toFixed(1)} kW${x.haatM ? `, ${x.haatM.toFixed(0)} m up` : ''}${x.atsc3 ? ' <span class="chip ng">ATSC 3.0</span>' : ''}</li>`).join('');2766- const first = m.channels.length ? m.channels[0] : '';2767- box.innerHTML = `2768- <div class="ad-head"><b>RF ${m.rf}</b><span>${m.frequencyMhz} MHz ${esc(m.band)}</span><span class="ad-scan">${esc(scan)}</span></div>2769- <div class="ad-sig">${meter(s, true)}<div><div class="ad-label" style="color:${s.state === 'none' ? 'var(--muted)' : s.color}">${esc(s.label)}</div><div class="ad-sub">${esc(sigDetail(s))}</div></div></div>2770- ${bars ? `<div class="ad-hist" aria-label="Readings over time">${bars}</div><div class="ad-axis"><span>${esc(ago(Date.parse(wins[0].from)))}</span><span>${esc(ago(Date.parse(wins[wins.length - 1].to)))}</span></div>` : ''}2771- ${chans ? `<div class="ad-chans">${chans}</div>` : ''}2772- ${stations ? `<ul class="ad-stations">${stations}</ul>` : ''}2773- ${first && antUI.zone === 'table' ? `<div class="ad-hint">${hints([['Enter', 'Enter', `watches ${first}`]])}</div>` : ''}`;2774-}2775-2776-// compactCarries turns ["4.1","4.2","2.1 (3.0)"] into "4.1-2 3.0: 2.1".2777-function compactCarries(list) {2778- const fold = (items) => {2779- const majors = new Map();2780- for (const c of items) {2781- const [maj, min] = c.split('.');2782- if (!majors.has(maj)) majors.set(maj, []);2783- majors.get(maj).push(Number(min || 0));2784- }2785- return [...majors].map(([maj, mins]) => (mins.length > 1 ? `${maj}.${Math.min(...mins)}-${Math.max(...mins)}` : `${maj}${mins[0] ? '.' + mins[0] : ''}`)).join(' ');2786- };2787- const v1 = list.filter((c) => !c.endsWith('(3.0)'));2788- const v3 = list.filter((c) => c.endsWith('(3.0)')).map((c) => c.replace(' (3.0)', ''));2789- return [fold(v1), v3.length ? `3.0: ${fold(v3)}` : ''].filter(Boolean).join(' ');2790-}2791-2792-const svgEl = (tag, attrs, text) => {2793- const el = document.createElementNS('http://www.w3.org/2000/svg', tag);2794- for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);2795- if (text != null) el.textContent = text;2796- return el;2797-};2798-2799-// renderRadar places the listed RF channels' transmitters around home, by2800-// tower site, colored by what was measured on their RF channels.2801-function renderRadar(muxes) {2802- const svg = $('.radar');2803- svg.innerHTML = '';2804- const R = 200;2805- const maxKm = state.report.radiusKm || 160;2806- const rad = (km) => R * Math.sqrt(Math.min(km, maxKm) / maxKm);2807- const pos = (km, deg) => {2808- const a = (deg - 90) * Math.PI / 180;2809- return [rad(km) * Math.cos(a), rad(km) * Math.sin(a)];2810- };2811- for (const km of [25, 50, 100, maxKm]) {2812- svg.append(svgEl('circle', { class: `ring${km === maxKm ? ' edge' : ''}`, r: rad(km) }));2813- svg.append(svgEl('text', { x: 3, y: -rad(km) - 3 }, `${km} km`));2814- }2815- svg.append(svgEl('line', { class: 'axis', x1: -R, y1: 0, x2: R, y2: 0 }));2816- svg.append(svgEl('line', { class: 'axis', x1: 0, y1: -R, x2: 0, y2: R }));2817- for (const [t, x, y] of [['N', 0, -R - 8], ['E', R + 10, 4], ['S', 0, R + 16], ['W', -R - 10, 4]]) {2818- svg.append(svgEl('text', { class: 'card', x, y, 'text-anchor': 'middle' }, t));2819- }2820- // Tower sites: the stations on the listed RF channels, by place.2821- const rank = { lock: 3, part: 2, off: 1, none: 0 };2822- const sites = new Map();2823- for (const m of muxes) {2824- const sig = muxSig(m);2825- for (const x of m.stations) {2826- const full = state.bySite.get(`${x.facilityId}:${m.rf}`);2827- if (!full || !full.point) continue;2828- const k = `${full.point.lat.toFixed(3)},${full.point.lon.toFixed(3)}`;2829- const site = sites.get(k) || { x, rfs: [], calls: [], state: 'none', found: 0 };2830- site.rfs.push(m.rf);2831- site.calls.push(x.callSign);2832- if (rank[sig.state] > rank[site.state]) site.state = sig.state;2833- if (muxFound(m)) site.found++;2834- sites.set(k, site);2835- }2836- }2837- const labelled = new Map();2838- for (const site of sites.values()) {2839- if (site.found < 2) continue;2840- const city = site.x.city;2841- if (!labelled.has(city) || labelled.get(city).found < site.found) labelled.set(city, site);2842- }2843- const ordered = [...sites.values()].sort((a, b) => rank[a.state] - rank[b.state]);2844- const labels = [];2845- for (const site of ordered) {2846- const [x, y] = pos(site.x.distanceKm, site.x.bearingDeg);2847- const r = 3 + Math.min(7, Math.sqrt(site.rfs.length) * 2);2848- const dot = svgEl('circle', { class: `site-dot s-${site.state}`, cx: x, cy: y, r, 'data-rf': site.rfs[0] });2849- dot.dataset.rfs = site.rfs.join(' ');2850- dot.append(svgEl('title', {}, `${site.calls.join(', ')}\n${site.x.city}, ${site.x.distanceKm.toFixed(0)} km ${compass(site.x.bearingDeg)}, RF ${site.rfs.join(', ')}`));2851- svg.append(dot);2852- if ([...labelled.values()].includes(site)) labels.push({ x, y, r, text: `${site.x.city} ${site.found}` });2853- }2854- const placed = [];2855- for (const l of labels.sort((a, b) => a.text.length - b.text.length)) {2856- const left = l.x < 0;2857- const w = l.text.length * 7.8;2858- const x0 = left ? l.x - l.r - 5 - w : l.x + l.r + 5;2859- const box = { x0, x1: x0 + w, y0: l.y - 9, y1: l.y + 6 };2860- if (box.x0 < -224 || box.x1 > 224 || placed.some((b) => box.x0 < b.x1 && b.x0 < box.x1 && box.y0 < b.y1 && b.y0 < box.y1)) continue;2861- placed.push(box);2862- svg.append(svgEl('text', { class: 'site', x: left ? l.x - l.r - 5 : l.x + l.r + 5, y: l.y + 4, 'text-anchor': left ? 'end' : 'start' }, l.text));2863- }2864- svg.append(svgEl('circle', { class: 'home', r: 4 }));2865- svg.append(svgEl('line', { class: 'ray', id: 'ray', x1: 0, y1: 0, x2: 0, y2: 0 }));2866- markRadar();2867-}2868-2869-// markRadar marks the picked RF channel's transmitter, with a ray to it.2870-function markRadar() {2871- $$('.radar .site-dot').forEach((d) => d.classList.toggle('sel', (d.dataset.rfs || '').split(' ').includes(String(state.aRF))));2872- const ray = $('#ray');2873- const m = state.byRF.get(state.aRF);2874- const st = m && muxStation(m);2875- if (!ray) return;2876- if (!st) {2877- ray.setAttribute('x2', 0);2878- ray.setAttribute('y2', 0);2879- return;2880- }2881- const maxKm = state.report.radiusKm || 160;2882- const rr = 200 * Math.sqrt(Math.min(st.distanceKm, maxKm) / maxKm);2883- const a = (st.bearingDeg - 90) * Math.PI / 180;2884- ray.setAttribute('x2', rr * Math.cos(a));2885- ray.setAttribute('y2', rr * Math.sin(a));2886-}2887-2888-// renderNextGen lists the stations broadcasting ATSC 3.0 here, as facts2889-// about them: what this tuner can't take in.2890-function renderNextGen() {2891- const box = f($('#antenna'), 'nextgen');2892- const hosts = state.report.atsc3 || [];2893- if (!hosts.length) {2894- box.innerHTML = '';2895- return;2896- }2897- const items = hosts.map((h) => {2898- const svcs = (h.services || []).map((v) => `<span><i>${esc(v.display.replace('-', '.').replace(/^0/, ''))}</i> ${esc(v.network || v.name)}</span>`).join(' ');2899- return `<div class="ng-host"><b>${esc(h.callSign)}</b> <em>RF ${esc(h.rf)}</em> ${svcs}</div>`;2900- }).join('');2901- box.innerHTML = `<div class="ng-label">NextGen TV (ATSC 3.0) on the air here${nextGen() ? '' : '<small>This tuner takes in ATSC 1.0 only</small>'}</div><div class="ng-hosts">${items}</div>`;2902-}2903-2904-// ---------- settings ----------2905-// More is Settings: a tab for each area in the rail, and beside it the2906-// tab's rows, or Reception's measurements. For a remote or a controller2907-// first (a mouse and a keyboard work too). On the tabs Up and Down pick2908-// one, Right or OK goes into it. In a list Up and Down move, Left and Right2909-// change a choice or a switch, Enter switches, runs or opens a list (hidden2910-// channels, the controls, data sources), and Left on a row with nothing to2911-// change goes back to the tabs. Page Up and Down (a controller's LB and2912-// RB) change tab anywhere, Up from the top moves to the dock, and Esc goes2913-// back. Changes are saved as they're made. The server's address and token2914-// need a keyboard: Enter on their row puts the cursor in them.2915-const settingsUI = { tab: 'display', zone: 'tabs', at: 0, sub: null, from: 0, rows: [], pending: {}, timers: {}, tested: '' };2916-2917-// SETTINGS_TABS are the areas in the rail's order, [id, label]; a row's2918-// section is its tab's label.2919-const SETTINGS_TABS = [['display', 'Display'], ['watching', 'Watching'], ['channels', 'Channels'], ['reception', 'Reception'],2920- ['recordings', 'Recordings'], ['server', 'Server'], ['about', 'About']];2921-2922-const AUDIO_LANGS = [['', 'As broadcast'], ['en', 'English'], ['es', 'Spanish'], ['fr', 'French'], ['ko', 'Korean'],2923- ['vi', 'Vietnamese'], ['zh', 'Chinese'], ['ru', 'Russian'], ['pt', 'Portuguese']];2924-const GUIDE_HOURS = [6, 12, 24, 36, 48, 72];2925-const WATCHED = [[0, 'Keep them'], [1, 'Delete after a day'], [7, 'Delete after a week'], [30, 'Delete after a month']];2926-const SETTINGS_LISTS = { hidden: 'Hidden channels', controls: 'Remote, controller and keys', sources: 'Data sources' };2927-// How to hide a channel, with the keys there are.2928-const HIDE_HOW = () => (onRemote() ? 'In the guide, hold OK on a channel\'s program: Hide channel' : 'In the guide, H hides a channel');2929-2930-// padNames names face buttons ("A, Y") as the pad in use labels them, by2931-// their place in the standard mapping.2932-const FACES = {2933- xbox: ['A', 'B', 'X', 'Y'], xboxswap: ['A', 'B', 'Y', 'X'], switch: ['B', 'A', 'Y', 'X'],2934- playstation: ['Cross', 'Circle', 'Square', 'Triangle'], steamdeck: ['A', 'B', 'X', 'Y'],2935-};2936-const padNames = (names) => names.replace(/\b[ABXY]\b/g, (n) => (FACES[prompts.family] || FACES.xbox)['ABXY'.indexOf(n)]);2937-2938-// CONTROLS is the legend of the controls list: glyph keys, the controller's2939-// buttons by name (for a keyboard user), the keys, and what they do.2940-const CONTROLS = [2941- ['Arrows', 'D-pad, left stick', 'Arrows', 'Move. On TV, Up and Down change channel, Left and Right go back 10 s or ahead 30 s and bring up the controls'],2942- ['Enter', 'A', 'Enter', 'Choose. On TV, Enter (the remote\'s OK) brings up the controls, Guide first, and A plays and pauses'],2943- ['Escape', 'B', 'Esc, Back', 'Back, and puts the controls away'],2944- ['g', 'Y', 'G', 'Guide'],2945- ['i', 'X', 'I', 'Program info, twice for the measured signal. In the guide, more for a program: record, favorite, hide'],2946- ['PageUp PageDown', 'LB, RB', 'Page Up, Page Down', 'Channel down or up, a page of the guide or of Settings'],2947- ['ShiftLeft ShiftRight', 'LT, RT', 'Shift Left, Shift Right', 'Back or ahead a minute'],2948- ['Space', 'R3', 'Space, Play', 'Pause'],2949- ['m', 'L3', 'M', 'Mute'],2950- [',', 'Start', ',', 'Settings'],2951- ['w', 'Back', 'W', 'Weather'],2952-];2953-2954-function wireSettings() {2955- const v = $('#settings');2956- const list = f(v, 'list');2957- list.addEventListener('click', (e) => {2958- const el = e.target.closest('.s-row');2959- if (!el) return;2960- const i = Number(el.dataset.i);2961- if (i !== settingsUI.at) moveSetting(i, false);2962- if (e.target.closest('input')) return;2963- const r = settingsUI.rows[i];2964- const step = e.target.closest('[data-step]');2965- if (r.kind === 'choice') stepSetting(r, step ? Number(step.dataset.step) : 1, !step);2966- else activateSetting(r);2967- });2968- // The text rows: Enter keeps what was typed, Esc puts it back.2969- list.addEventListener('keydown', (e) => {2970- const input = e.target.closest('input');2971- if (!input) return;2972- if (e.key === 'Enter') { e.preventDefault(); input.blur(); }2973- if (e.key === 'Escape') {2974- e.preventDefault();2975- e.stopPropagation();2976- input.value = input.dataset.was;2977- input.blur();2978- }2979- });2980- list.addEventListener('change', (e) => {2981- if (e.target.name === 'server' || e.target.name === 'token') saveServer(e.target.name, e.target.value);2982- });2983- list.addEventListener('focusout', () => setTimeout(renderSettings));2984- f(v, 'tabs').addEventListener('click', (e) => {2985- const b = e.target.closest('[data-tab]');2986- if (b) pickSettingsTab(b.dataset.tab, 'list');2987- });2988-}2989-2990-// settingsTabs are the tabs there are: Reception with an antenna, and2991-// those with rows.2992-function settingsTabs() {2993- const sections = new Set(settingsRows().map((r) => r.section));2994- return SETTINGS_TABS.filter(([k, label]) => (k === 'reception' ? !!state.info && state.info.antenna !== false : sections.has(label)));2995-}2996-2997-const receptionShown = () => state.view === 'settings' && settingsUI.tab === 'reception';2998-2999-// openSettings shows a tab, the one left open when none is asked for, with3000-// the focus on the tabs or in the tab (zone 'list').
diff truncated
M frontend/dist/index.html +16 -5
@@ -29,7 +29,7 @@
2929 <div id="entry" aria-live="assertive"></div>3030 3131 <!-- The channel banner, and with OK the on-screen controls: a timeline32- and a row of buttons below it (app.js, on-screen controls). -->32+ and a row of buttons below it (js/player.js, on-screen controls). -->3333 <div id="banner" aria-live="polite">3434 <div class="bn-chan">3535 <div class="bn-num" data-f="num"></div>
@@ -97,7 +97,7 @@
9797 <div class="wx-now" data-f="now"></div>9898 <button type="button" class="btn ghost fx wx-watch" data-act="watch" hidden>Watch the weather channel</button>9999 </nav>100- <!-- Each part takes the focus in turn, Up and Down (app.js, weather). -->100+ <!-- Each part takes the focus in turn, Up and Down (js/weather.js). -->101101 <div class="main wx-main">102102 <div class="wx-alerts fx" data-f="alerts" tabindex="-1"></div>103103 <div class="wx-block fx" tabindex="-1">
@@ -128,7 +128,7 @@
128128 129129 <!-- More: Settings, a tab for each area, in the rail; most are a list of130130 rows for a remote or a controller, Reception is what the tuners131- measured (app.js, settings and reception). -->131+ measured (js/settings.js, js/reception.js). -->132132 <section id="settings" class="view railed" aria-label="Settings">133133 <nav class="rail"><div class="rail-menu s-tabs" data-f="tabs" role="tablist"></div></nav>134134 <div class="main s-main">
@@ -183,7 +183,7 @@
183183 <div class="boot-sub" data-f="sub">Over-the-air television</div>184184 <ol class="boot-log"></ol>185185 <!-- The arrows move between the fields and Connect; OK on a field186- puts the cursor in it (app.js, bootKey). -->186+ puts the cursor in it (js/input.js, bootKey). -->187187 <form class="boot-connect" hidden autocomplete="off">188188 <label class="boot-field fx" tabindex="-1"><input name="server" placeholder="Server address" autocapitalize="off" spellcheck="false"></label>189189 <label class="boot-field fx" tabindex="-1"><input name="token" type="password" placeholder="Token, if set"></label>
@@ -200,6 +200,17 @@
200200 <script src="vendor/hls.min.js"></script>201201 <!-- The web app's stand-in for Wails, at /tv/ on airwavesd (web.js) -->202202 <script src="web.js"></script>203- <script src="app.js"></script>203+ <!-- The app, by area, in this order: what runs as a file loads uses only204+ what the files before it define (init starts once all are in). -->205+ <script src="js/core.js"></script>206+ <script src="js/player.js"></script>207+ <script src="js/input.js"></script>208+ <script src="js/guide.js"></script>209+ <script src="js/reception.js"></script>210+ <script src="js/settings.js"></script>211+ <script src="js/recordings.js"></script>212+ <script src="js/captions.js"></script>213+ <script src="js/playback.js"></script>214+ <script src="js/weather.js"></script>204215 </body>205216 </html>
A frontend/dist/js/captions.js +602 -0
@@ -0,0 +1,602 @@
1+'use strict';2+3+// Captions, drawn by the app, and audio tracks.4+5+// ---------- captions ----------6+// Captions follow the saved setting, except that programs whose sound7+// isn't English start with them on. C during such a program turns them8+// off or on for it alone, leaving the setting as it was.9+const ENGLISH = /^(en|eng|english)([-_].*)?$/i;10+const UNKNOWN = /^(|und|unknown|mul|mis|zxx|qaa)$/i;11+const foreignAudio = (p) => !!p && !!p.audioLang && !ENGLISH.test(p.audioLang) && !UNKNOWN.test(p.audioLang);12+13+function programOn() {14+ if (state.recording || !state.current) return null;15+ return airingAt(state.current, Date.now()) || null;16+}17+18+const programKey = (p) => `${state.current && state.current.key}|${p.start}`;19+20+function captionsWanted() {21+ const p = programOn();22+ if (foreignAudio(p)) {23+ const o = state.captionsFor;24+ return o && o.key === programKey(p) ? o.on : true;25+ }26+ return !!state.settings.captions;27+}28+29+// The app draws captions itself, the same on macOS and Linux, from the30+// stream's text track, which it keeps hidden from the webview: a custom31+// channel's WebVTT subtitles (timed word by word when YouTube's speech32+// recognition made them), else a broadcast's CEA-608 captions as hls.js or33+// WebKit decode them. They sit low on the picture, above the banner while34+// it shows, sized to the picture and the interface size: at most two35+// lines, as even as they'll go, broken where the source broke them when36+// that fits. A caption too long for two lines is paged, each page filling37+// the box in turn, and timed words appear as they're said. Pretext38+// (vendor/pretext) measures the words; canvas does until it has loaded,39+// or if it can't.40+const CAP_FAMILY = '"Schibsted", "Helvetica Neue", sans-serif';41+const CAP_HEIGHT = 0.046; // font size, of the picture's height at 100%42+const CAP_EMS = 21; // the widest line, in ems: about 42 characters43+const cap = {44+ el: $('#captions'),45+ track: null, // the text track drawn46+ timedTrack: false, // whether it times words47+ checked: 0, // how many of its cues that's from48+ m: null, // sizes: { size, max, bottom, pad, font, key }49+ key: '', // the page drawn, at its size50+ page: null,51+ shown: -1, // how many of its words show52+ spans: [], // its word elements, timed pages only53+ bars: [],54+ logical: null, // the track's cues, whole: { sig, cues }55+ timed: null, // a timed track's words, paged56+ frozen: new Map(),// timed pages shown, as they were laid out57+ units: new Map(), // untimed captions, paged, by cue58+ raf: 0,59+ ready: false, // the caption font has loaded60+};61+let pretext = null;62+import('../vendor/pretext/rich-inline.js') // from this file's folder63+ .then((m) => { pretext = m; resetCaptionLayout(); })64+ .catch((e) => log('info', `captions measured with canvas: ${e}`));65+document.fonts.load(`500 32px ${CAP_FAMILY}`).catch(() => {}).then(() => { cap.ready = true; resetCaptionLayout(); });66+67+function captionTracks() {68+ return [...(video.textTracks || [])].filter((t) => t.kind === 'captions' || t.kind === 'subtitles');69+}70+71+// applyCaptions shows or hides captions as wanted, from the text subtitles72+// when there are some (a custom channel has those and the same words as73+// 608 in the picture), else the captions in the picture. It reports74+// whether the stream has any.75+function applyCaptions() {76+ const { tracks, pick, on } = captionPlan();77+ for (const t of tracks) {78+ const mode = on && t === pick ? 'hidden' : 'disabled';79+ if (t.mode !== mode) t.mode = mode;80+ }81+ // hls.js stops fetching subtitles nobody's reading.82+ if (!on && state.hls && state.hls.subtitleTrack !== -1) state.hls.subtitleTrack = -1;83+ setCaptionTrack(on ? pick : null);84+ return !!pick;85+}86+87+// captionPlan is which tracks there are, which one captions come from,88+// and whether they're wanted.89+function captionPlan() {90+ const tracks = captionTracks();91+ const pick = tracks.find((t) => t.kind === 'subtitles') || tracks[0] || null;92+ return { tracks, pick, on: !!state.settings && captionsWanted() };93+}94+95+// Only the app turns captions on: when a player or the webview changes a96+// track (picking one itself, or following a system caption setting), the97+// app sets them back as wanted.98+function keepCaptions() {99+ const { tracks, pick, on } = captionPlan();100+ if (tracks.some((t) => t.mode !== (on && t === pick ? 'hidden' : 'disabled'))) applyCaptions();101+}102+103+async function toggleCaptions() {104+ const p = programOn();105+ if (foreignAudio(p)) state.captionsFor = { key: programKey(p), on: !captionsWanted() };106+ else await saveAppSettings({ ...state.settings, captions: !state.settings.captions });107+ const found = applyCaptions();108+ toast(captionsWanted() ? (found ? 'Captions on' : 'Captions on (this program has none)') : 'Captions off', 1800);109+}110+111+function setCaptionTrack(t) {112+ if (t === cap.track) return;113+ cap.track = t;114+ cap.timedTrack = false;115+ cap.checked = 0;116+ cap.logical = null;117+ cap.timed = null;118+ cap.frozen.clear();119+ cap.units.clear();120+ drawCaption(null);121+ if (t && !cap.raf) cap.raf = requestAnimationFrame(captionFrame);122+}123+124+// resetCaptionLayout lays captions out again: the picture, the banner or125+// the font changed.126+function resetCaptionLayout() {127+ cap.m = null;128+ cap.key = '';129+}130+131+function captionFrame() {132+ cap.raf = 0;133+ if (!cap.track) return;134+ if (state.view === 'tv' && cap.ready) renderCaption(video.currentTime);135+ cap.raf = requestAnimationFrame(captionFrame);136+}137+138+// captionSizes fits captions to the picture within the stage, in CSS139+// pixels: the font a share of the picture's height, scaled with the140+// interface, and the lines' bottom a little above the picture's, or above141+// the banner or the weather crawl.142+function captionSizes() {143+ const w = stage.clientWidth;144+ const h = stage.clientHeight;145+ if (!w || !h) return null;146+ const ar = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 16 / 9;147+ const pw = Math.min(w, h * ar);148+ const ph = pw / ar;149+ // Larger with the interface, though only half as fast: at 150% they'd150+ // otherwise crowd the picture.151+ const z = 1 + (((state.settings && state.settings.scale) || autoScale()) / 100 - 1) / 2;152+ const size = Math.max(12, Math.round(ph * CAP_HEIGHT * z));153+ const max = Math.round(Math.min(pw * 0.88, size * CAP_EMS));154+ let bottom = (h - ph) / 2 + ph * 0.055;155+ const banner = $('#banner');156+ if (state.view === 'tv' && banner.classList.contains('show')) bottom = Math.max(bottom, h - banner.offsetTop + size * 0.45);157+ const crawl = $('#crawl');158+ if (crawl && document.body.classList.contains('has-crawl')) bottom = Math.max(bottom, crawl.offsetHeight + size * 0.45);159+ return { size, max, bottom: Math.round(bottom), pad: size * 0.3, font: `500 ${size}px ${CAP_FAMILY}`, key: `${size}/${max}` };160+}161+162+function renderCaption(t) {163+ if (!cap.m) {164+ cap.m = captionSizes();165+ if (!cap.m) return;166+ cap.el.style.fontSize = `${cap.m.size}px`;167+ cap.el.style.bottom = `${cap.m.bottom}px`;168+ }169+ const track = cap.track;170+ const n = track.cues ? track.cues.length : 0;171+ if (!cap.timedTrack && n !== cap.checked) {172+ cap.timedTrack = timesWords(track);173+ cap.checked = n;174+ }175+ const at = cap.timedTrack ? timedPage(track, t) : untimedPage(track, t);176+ drawCaption(at, t);177+}178+179+// ----- cue text -----180+const VTT_TIME = /^<(?:(\d+):)?(\d\d):(\d\d)\.(\d{3})>$/;181+const CUE_ENTITIES = { amp: '&', lt: '<', gt: '>', nbsp: ' ', quot: '"', apos: "'", lrm: '', rlm: '' };182+const decodeCue = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e) => {183+ if (e[0] !== '#') return CUE_ENTITIES[e.toLowerCase()] ?? all;184+ return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));185+}).replace(/¶/g, '♪'); // a music note, as captions made from broadcast ones misname it186+const parsedCues = new Map(); // by start and text187+188+function cueText(cue) {189+ if (typeof cue.text === 'string') return cue.text;190+ return cue.getCueAsHTML ? cue.getCueAsHTML().textContent : '';191+}192+193+// logicalCues is a track's cues as the stream sent them, by start. WebKit194+// splits a cue that shows alongside others into pieces with its text, one195+// for each stretch the set showing stays the same; they're joined again.196+function logicalCues(track) {197+ const list = track.cues;198+ const n = list ? list.length : 0;199+ const sig = n ? `${n}|${list[0].startTime}|${list[n - 1].startTime}` : '';200+ if (cap.logical && cap.logical.sig === sig) return cap.logical.cues;201+ const cues = [];202+ const open = new Map();203+ for (let i = 0; i < n; i++) {204+ const c = list[i];205+ const text = cueText(c);206+ const l = open.get(text);207+ if (l && c.startTime <= l.endTime + 0.05) {208+ l.endTime = Math.max(l.endTime, c.endTime);209+ continue;210+ }211+ const cue = { startTime: c.startTime, endTime: c.endTime, text, line: c.line };212+ open.set(text, cue);213+ cues.push(cue);214+ }215+ cap.logical = { sig, cues };216+ return cues;217+}218+219+// cueLines reads a cue into lines of words, each with when it's said:220+// the cue's start, or the WebVTT timestamp before it.221+function cueLines(cue) {222+ const id = `${cue.startTime}\n${cue.text}`;223+ let lines = parsedCues.get(id);224+ if (lines) {225+ for (const l of lines) for (const w of l) w.end = cue.endTime;226+ return lines;227+ }228+ if (parsedCues.size > 5000) parsedCues.clear();229+ lines = [];230+ for (const raw of cue.text.split(/\r?\n/)) {231+ let at = cue.startTime;232+ const words = [];233+ for (const part of raw.split(/(<[^>]*>)/)) {234+ if (part[0] === '<') {235+ const m = VTT_TIME.exec(part);236+ if (m) at = (+m[1] || 0) * 3600 + +m[2] * 60 + +m[3] + +m[4] / 1000;237+ continue;238+ }239+ for (const w of decodeCue(part).split(/\s+/)) {240+ if (w) words.push({ text: w, at, end: cue.endTime });241+ }242+ }243+ if (words.length) lines.push(words);244+ }245+ parsedCues.set(id, lines);246+ return lines;247+}248+249+// timesWords reports whether a track's captions time their words, as250+// speech recognition's do (though not every cue: a one-word line has no251+// time but its start).252+function timesWords(track) {253+ for (let i = 0; i < Math.min(track.cues.length, 40); i++) {254+ if (/<(\d+:)?\d\d:\d\d\.\d{3}>/.test(cueText(track.cues[i]))) return true;255+ }256+ return false;257+}258+259+// ----- measuring -----260+const capWidths = new Map(); // font and word: width261+let capCanvas = null;262+263+function canvasFont(font) {264+ if (!capCanvas) capCanvas = document.createElement('canvas').getContext('2d');265+ capCanvas.font = font;266+ return capCanvas;267+}268+269+// measureWords returns the width of each word in font.270+function measureWords(words, font) {271+ if (capWidths.size > 20000) capWidths.clear();272+ const out = new Array(words.length);273+ const todo = [];274+ words.forEach((w, i) => {275+ const v = capWidths.get(`${font}\n${w}`);276+ if (v === undefined) todo.push(i); else out[i] = v;277+ });278+ if (todo.length && pretext) {279+ const p = pretext.prepareRichInline(todo.map((i, k) => ({ text: (k ? ' ' : '') + words[i], font, break: 'never' })));280+ pretext.walkRichInlineLineRanges(p, 1e9, (range) => {281+ for (const fr of pretext.materializeRichInlineLineRange(p, range).fragments) out[todo[fr.itemIndex]] = fr.occupiedWidth;282+ });283+ }284+ for (const i of todo) {285+ if (out[i] === undefined) out[i] = canvasFont(font).measureText(words[i]).width;286+ capWidths.set(`${font}\n${words[i]}`, out[i]);287+ }288+ return out;289+}290+291+const spaceWidth = (font) => measureWords(['a\u00a0a', 'aa'], font).reduce((x, y) => x - y);292+293+// ----- laying out -----294+const SENTENCE_END = /[.!?…♪]["'”’)\]]*$/;295+const CLAUSE_END = /[,;:.!?…—]["'”’)\]]*$/;296+297+// pageWords splits words from..to into pages of at most two lines no298+// wider than max, each as full as it goes but ending a sentence where one299+// ends late in it, and the last not left with a word or two. A word300+// marked brk starts a line (a change of speaker).301+function pageWords(words, widths, space, max, from = 0, to = words.length) {302+ // fill is where a page from i ends when filled.303+ const fill = (i) => {304+ let line = 1;305+ let start = i;306+ let w = 0;307+ for (let j = i; j < to; j++) {308+ if (j > start && (words[j].brk || w + space + widths[j] > max)) {309+ if (line === 2) return j;310+ line = 2;311+ start = j;312+ w = widths[j];313+ } else {314+ w += (j > start ? space : 0) + widths[j];315+ }316+ }317+ return to;318+ };319+ const ranges = [];320+ for (let i = from; i < to;) {321+ let end = fill(i);322+ if (end < to) {323+ for (let k = end - 1; k > i + (end - i) / 2; k--) {324+ if (SENTENCE_END.test(words[k].text)) { end = k + 1; break; }325+ }326+ }327+ ranges.push([i, end]);328+ i = end;329+ }330+ const chars = (a, b) => { let n = 0; for (let i = a; i < b; i++) n += words[i].text.length + 1; return n; };331+ const n = ranges.length;332+ if (n > 1 && chars(...ranges[n - 1]) < 0.35 * chars(...ranges[n - 2])) {333+ // Even out the last two pages, splitting after punctuation if near.334+ const [a] = ranges[n - 2];335+ const b = ranges[n - 1][1];336+ let best = Infinity;337+ for (let k = a + 1; k < b; k++) {338+ if (fill(a) < k || fill(k) < b) continue;339+ const c = Math.abs(chars(a, k) - chars(k, b)) - (CLAUSE_END.test(words[k - 1].text) ? 8 : 0);340+ if (c < best) { best = c; ranges[n - 2] = [a, k]; ranges[n - 1] = [k, b]; }341+ }342+ }343+ return ranges.map(([a, b]) => balance(words, widths, space, max, a, b));344+}345+346+// balance puts words a..b on one line if they fit, else two of about347+// equal width, breaking after punctuation where it can, at a change of348+// speaker where there is one.349+function balance(words, widths, space, max, a, b) {350+ const sum = [0];351+ for (let i = a; i < b; i++) sum.push(sum[sum.length - 1] + widths[i] + space);352+ const width = (x, y) => sum[y - a] - sum[x - a] - space;353+ for (let k = a + 1; k < b; k++) {354+ if (words[k].brk) return { from: a, to: b, lines: [[a, k], [k, b]] };355+ }356+ if (width(a, b) <= max || b - a < 2) return { from: a, to: b, lines: [[a, b]] };357+ let best = Infinity;358+ let at = a + 1;359+ for (let k = a + 1; k < b; k++) {360+ const l = width(a, k);361+ const r = width(k, b);362+ if (l > max || r > max) continue;363+ const c = Math.abs(l - r) + (l > r ? space : 0) - (CLAUSE_END.test(words[k - 1].text) ? 3 * space : 0);364+ if (c < best) { best = c; at = k; }365+ }366+ return { from: a, to: b, lines: [[a, at], [at, b]] };367+}368+369+// speakerMark reports whether a word marks a change of speaker, as370+// speech recognition and broadcast captions do with ">>".371+const speakerMark = (w) => w.startsWith('>>');372+373+// ----- choosing what shows -----374+// cuesAt lists a track's cues showing at t, top row first.375+function cuesAt(track, t) {376+ const list = logicalCues(track);377+ let lo = 0;378+ let hi = list.length - 1;379+ let i = -1;380+ while (lo <= hi) {381+ const mid = (lo + hi) >> 1;382+ if (list[mid].startTime <= t) { i = mid; lo = mid + 1; } else hi = mid - 1;383+ }384+ const out = [];385+ for (let k = i; k >= 0 && k > i - 40; k--) if (list[k].endTime > t) out.push(list[k]);386+ const row = (c) => (typeof c.line === 'number' ? c.line : 0);387+ return out.sort((x, y) => row(x) - row(y) || x.startTime - y.startTime);388+}389+390+// untimedPage is what shows at t of captions without word times: the391+// cues showing, laid out together and paged over their time.392+function untimedPage(track, t) {393+ const cues = cuesAt(track, t);394+ if (!cues.length) return null;395+ const key = cues.map((c) => `${c.startTime}/${c.text}`).join('\n');396+ let u = cap.units.get(key);397+ if (!u || u.end !== Math.max(...cues.map((c) => c.endTime))) {398+ if (cap.units.size > 200) cap.units.clear();399+ const m = cap.m;400+ // Overlapping cues showing the same line show it once, oldest first.401+ const seen = new Set();402+ const lines = cues.flatMap(cueLines).filter((l) => {403+ const text = l.map((w) => w.text).join(' ');404+ return !seen.has(text) && seen.add(text);405+ });406+ const words = [];407+ for (const [i, l] of lines.entries()) {408+ for (const [k, w] of l.entries()) words.push({ ...w, brk: (k === 0 && i > 0 && /^[-–—]/.test(w.text)) || speakerMark(w.text) });409+ }410+ const widths = measureWords(words.map((w) => w.text), m.font);411+ const space = spaceWidth(m.font);412+ // Lines that fit stay as the source broke them.413+ let pages;414+ const lineWidth = (from, n) => widths.slice(from, from + n).reduce((s, x) => s + x, 0) + (n - 1) * space;415+ let from = 0;416+ if (lines.length <= 2 && lines.every((l) => { const ok = lineWidth(from, l.length) <= m.max; from += l.length; return ok; })) {417+ const cut = lines[0].length;418+ pages = [{ from: 0, to: words.length, lines: lines.length === 2 ? [[0, cut], [cut, words.length]] : [[0, words.length]] }];419+ } else {420+ pages = pageWords(words, widths, space, m.max);421+ }422+ // Pages share the cues' time out by length.423+ const start = Math.min(...cues.map((c) => c.startTime));424+ const end = Math.max(...cues.map((c) => c.endTime));425+ const chars = (p) => words.slice(p.from, p.to).reduce((s, w) => s + w.text.length + 1, 0);426+ const total = pages.reduce((s, p) => s + chars(p), 0);427+ let done = 0;428+ for (const [i, p] of pages.entries()) {429+ p.start = start + ((end - start) * done) / total;430+ done += chars(p);431+ p.end = i === pages.length - 1 ? end : start + ((end - start) * done) / total;432+ p.id = `${key}#${i}`;433+ }434+ u = { words, widths, space, pages, end };435+ cap.units.set(key, u);436+ }437+ const page = u.pages.find((p) => p.start <= t && t < p.end) || u.pages[u.pages.length - 1];438+ return { page, words: u.words, widths: u.widths, space: u.space };439+}440+441+// timedPage is what shows at t of captions timed word by word: the442+// track's words, in runs of speech split at pauses, paged; a page shows443+// from its first word until the next page's, its words as they're said.444+// Captions arrive a little ahead of the picture, so a page is laid out445+// with what's known then; once it shows it stays as it is (frozen), and446+// words that come later go on the pages after.447+const wordKey = (w) => `${w.at}\n${w.text}`;448+449+function timedPage(track, t) {450+ const m = cap.m;451+ const list = logicalCues(track);452+ if (!list.length) return null;453+ const sig = `${cap.logical.sig}|${m.key}`;454+ if (!cap.timed || cap.timed.sig !== sig) {455+ const all = [];456+ // A cue's last line is its own: the server's screens roll up, the457+ // line above repeating the cue before's, which other sources overlap.458+ for (const c of list) {459+ const lines = cueLines(c);460+ if (lines.length) all.push(...lines[lines.length - 1]);461+ }462+ all.sort((x, y) => x.at - y.at);463+ const words = [];464+ for (const w of all) {465+ const last = words[words.length - 1];466+ if (last && last.at === w.at && last.text === w.text) last.end = Math.max(last.end, w.end);467+ else words.push({ ...w, brk: speakerMark(w.text) });468+ }469+ const widths = measureWords(words.map((w) => w.text), m.font);470+ const space = spaceWidth(m.font);471+ const pages = [];472+ for (let a = 0; a < words.length;) {473+ let b = a + 1;474+ while (b < words.length && words[b].at <= words[b - 1].end + 0.25 && words[b].at - words[b - 1].at < 4) b++;475+ const run = [];476+ let i = a;477+ // Pages already shown, as they were.478+ for (let f = cap.frozen.get(wordKey(words[i])); f && i < b; f = i < b && cap.frozen.get(wordKey(words[i]))) {479+ let j = i;480+ while (j < b && wordKey(words[j]) !== f.last) j++;481+ if (j === b) break;482+ let k = -1;483+ for (let x = i + 1; x <= j; x++) if (f.brk && wordKey(words[x]) === f.brk) k = x;484+ run.push({ from: i, to: j + 1, lines: k > 0 ? [[i, k], [k, j + 1]] : [[i, j + 1]] });485+ i = j + 1;486+ }487+ if (i < b) run.push(...pageWords(words, widths, space, m.max, i, b));488+ for (const [k, p] of run.entries()) {489+ p.start = words[p.from].at;490+ let end = 0;491+ for (let x = p.from; x < p.to; x++) end = Math.max(end, words[x].end);492+ p.end = k + 1 < run.length ? words[run[k + 1].from].at : Math.max(end, p.start + 1);493+ p.id = `${wordKey(words[p.from])}/${p.to - p.from}`;494+ pages.push(p);495+ }496+ a = b;497+ }498+ cap.timed = { sig, words, widths, space, pages };499+ }500+ const { pages, words } = cap.timed;501+ let lo = 0;502+ let hi = pages.length - 1;503+ let i = -1;504+ while (lo <= hi) {505+ const mid = (lo + hi) >> 1;506+ if (pages[mid].start <= t) { i = mid; lo = mid + 1; } else hi = mid - 1;507+ }508+ if (i < 0 || t >= pages[i].end) return null;509+ const page = pages[i];510+ if (!cap.frozen.has(wordKey(words[page.from]))) {511+ if (cap.frozen.size > 400) cap.frozen.delete(cap.frozen.keys().next().value);512+ const [, second] = page.lines;513+ cap.frozen.set(wordKey(words[page.from]), { last: wordKey(words[page.to - 1]), brk: second ? wordKey(words[second[0]]) : '' });514+ }515+ return { page, words, widths: cap.timed.widths, space: cap.timed.space, timed: true };516+}517+518+// ----- drawing -----519+function drawCaption(at, t) {520+ const key = at ? `${at.page.id}@${cap.m.key}` : '';521+ if (key !== cap.key) {522+ cap.key = key;523+ cap.page = at;524+ cap.shown = -1;525+ cap.spans = [];526+ cap.bars = [];527+ cap.el.textContent = '';528+ if (!at) return;529+ for (const [a, b] of at.page.lines) {530+ const line = document.createElement('div');531+ line.className = 'cap-line';532+ const bar = document.createElement('span');533+ bar.className = at.timed ? 'cap-bar timed' : 'cap-bar';534+ if (at.timed) {535+ for (let i = a; i < b; i++) {536+ const s = document.createElement('span');537+ s.textContent = (i > a ? ' ' : '') + at.words[i].text;538+ bar.append(s);539+ cap.spans.push({ el: s, i, bar, a });540+ }541+ cap.bars.push({ bar, a, b });542+ } else {543+ bar.textContent = at.words.slice(a, b).map((w) => w.text).join(' ');544+ }545+ line.append(bar);546+ cap.el.append(line);547+ }548+ }549+ if (!at || !at.timed) return;550+ // Timed words show as they're said, with the bar behind them growing.551+ const { page, words, widths, space } = at;552+ let n = page.from;553+ while (n < page.to && words[n].at <= t) n++;554+ if (n === cap.shown) return;555+ cap.shown = n;556+ for (const s of cap.spans) s.el.classList.toggle('wait', s.i >= n);557+ for (const { bar, a, b } of cap.bars) {558+ let w = 0;559+ for (let i = a; i < Math.min(b, n); i++) w += widths[i] + (i > a ? space : 0);560+ bar.style.setProperty('--r', n > a ? `${Math.ceil(w + 2 * cap.m.pad + 2)}px` : '0px');561+ }562+}563+564+// A new stream: the last one's captions go at once, not when its tracks do.565+video.addEventListener('emptied', () => setCaptionTrack(null));566+if (window.ResizeObserver) new ResizeObserver(resetCaptionLayout).observe(stage);567+video.addEventListener('resize', resetCaptionLayout);568+new MutationObserver(resetCaptionLayout).observe($('#banner'), { attributes: true, attributeFilter: ['class'] });569+new MutationObserver(resetCaptionLayout).observe(document.body, { attributes: true, attributeFilter: ['class'] });570+571+function audioTracks() {572+ if (state.hls) return state.hls.audioTracks.map((t, i) => ({ i, label: t.name, lang: t.lang, on: state.hls.audioTrack === i }));573+ const list = video.audioTracks;574+ if (!list) return [];575+ const out = [];576+ for (let i = 0; i < list.length; i++) out.push({ i, label: list[i].label, lang: list[i].language, on: list[i].enabled });577+ return out;578+}579+580+function selectAudio(i) {581+ if (state.hls) { state.hls.audioTrack = i; return; }582+ const list = video.audioTracks;583+ for (let j = 0; j < list.length; j++) list[j].enabled = j === i;584+}585+586+// applyAudio picks the preferred language when the stream offers it.587+function applyAudio() {588+ const want = state.settings.audioLang;589+ if (!want) return;590+ const t = audioTracks().find((x) => (x.lang || '').startsWith(want));591+ if (t && !t.on) selectAudio(t.i);592+}593+594+async function cycleAudio() {595+ const tracks = audioTracks();596+ if (tracks.length < 2) return toast('Only one audio track on this channel', 1800);597+ const cur = tracks.findIndex((t) => t.on);598+ const next = tracks[(cur + 1) % tracks.length];599+ selectAudio(next.i);600+ await saveAppSettings({ ...state.settings, audioLang: (next.lang || '').slice(0, 2) });601+ toast(`Audio: ${next.label || next.lang || `track ${next.i + 1}`}`, 1800);602+}
A frontend/dist/js/core.js +655 -0
@@ -0,0 +1,655 @@
1+'use strict';2+3+// State, data from the server, boot, and switching views.4+5+// ---------- helpers ----------6+const $ = (s, r = document) => r.querySelector(s);7+const $$ = (s, r = document) => [...r.querySelectorAll(s)];8+const f = (root, name) => root.querySelector(`[data-f="${name}"]`);9+const api = () => window.go && window.go.main && window.go.main.App;10+const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));11+const MIN = 60_000;12+const 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.15+const clockFormat = new Intl.DateTimeFormat([], { hour: 'numeric', minute: '2-digit' });16+const clock = (t) => clockFormat.format(new Date(t));17+const COMPASS = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];18+const compass = (deg) => COMPASS[Math.round(deg / 22.5) % 16];19+20+function log(level, msg) {21+ try { api().Log(level, String(msg)); } catch { /* not in Wails */ }22+}23+24+// ---------- 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.33+const SIG_COLOR = { lock: 'var(--amber)', part: 'var(--amber-2)', off: 'var(--red)', none: 'var(--faint)' };34+35+// 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.39+function 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+}62+63+// meter draws a measurement's bars.64+function 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+}69+70+// ago says how long ago a time was, roughly.71+function 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+}79+80+// agoShort is ago for a table: "10 min", "3 h", "Oct 3".81+function 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+}88+89+// sigDetail is a measurement's details in words: "strength 82%, 1890+// errors a second, 5 min ago".91+function 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+}101+102+// 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).104+const trouble = (s) => s.state === 'off' || s.state === 'part' || (s.errors != null && s.errors >= 5);105+106+let toastTimer = 0;107+function 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+}114+115+// 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.119+let lite = false;120+function setLite(on) {121+ lite = on;122+ document.body.classList.toggle('lite', on);123+}124+setLite(/Linux/.test(navigator.userAgent) && !/Android/.test(navigator.userAgent));125+126+// 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.130+const androidTV = /\bAirwavesTV\//.test(navigator.userAgent);131+132+// ---------- 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+// 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 say147+// otherwise on this device. What's pressed still counts: a controller on a148+// desktop brings its buttons to the hints, a keyboard on a TV its keys.149+const PLATFORMS = [['desktop', 'Desktop'], ['console', 'Console'], ['tv', 'TV']];150+const platformKey = 'airwaves.platform';151+let platform = 'desktop';152+let detectedPlatform = 'desktop';153+// onRemote: the hints and the ways to do things are a TV remote's.154+const onRemote = () => platform === 'tv' && !prompts.pad;155+// onKeys: they're a keyboard's, letters and all.156+const onKeys = () => platform !== 'tv' && !prompts.pad;157+158+function applyPlatform(detected) {159+ if (detected) detectedPlatform = PLATFORMS.some(([k]) => k === detected) ? detected : 'desktop';160+ const forced = localStorage.getItem(platformKey) || '';161+ const next = PLATFORMS.some(([k]) => k === forced) ? forced : detectedPlatform;162+ for (const [k] of PLATFORMS) document.body.classList.toggle(`pf-${k}`, k === next);163+ const changed = next !== platform;164+ platform = next;165+ // A console starts with its controller's buttons in the hints.166+ if (platform === 'console') setPrompts(true);167+ else if (changed) setPrompts(false);168+ return changed;169+}170+171+// ---------- state ----------172+const state = {173+ boot: null,174+ settings: null,175+ report: null,176+ guide: null,177+ byFacility: new Map(), // facilityId -> nearest station178+ bySite: new Map(), // "facilityId:rf" -> station179+ antenna: null, // the tuner and its RF channels, as last measured (/api/signal)180+ antennaChans: [], // the tuner's channels, hidden ones too181+ byRF: new Map(), // RF channel -> what's known of it (antenna.muxes)182+ sigStamp: 0, // counts signal updates, for redraws183+ lineup: [],184+ custom: [], // the server's own channels: the weather channel, folder, Jellyfin and YouTube channels185+ current: null,186+ previous: null,187+ note: '',188+ view: 'tv',189+ tuneToken: 0,190+ hls: null,191+ entry: '',192+ entryTimer: 0,193+ bannerTimer: 0,194+ guideFilter: 'all',195+ gStart: 0,196+ gSpan: 150,197+ gRow: 0,198+ gTime: 0,199+ aRF: 0, // the RF channel picked in Reception200+ info: null, // backend: local engine or a server201+ config: null, // location the lineup is built for202+ dvr: null, // recording state from the server203+ dvrKeys: new Map(), // "7.1@1791058239" -> upcoming item204+ recPick: '', // the airing whose Record is open to its choices205+ recording: null, // recording being played instead of live TV206+ captionsFor: null, // captions turned off or on for one program: { key, on }207+ recTab: 'library',208+ recSelBy: null,209+ dock: -1, // dock button picked with the arrow keys, or -1210+};211+212+const video = $('#video');213+const stage = $('#stage');214+215+// ---------- data ----------216+async function scan(refresh) {217+ const snap = await api().Scan(refresh);218+ state.report = snap.report;219+ state.guide = snap.guide || { programs: {} };220+ // The antenna channels are the tuner's: those its scan found, described221+ // from the FCC's records and the listings. Their keys (for favorites and222+ // hidden channels) carry the listings' call sign where there is one223+ // ("2.1|KWGNDT"), as keys always have, else the station's.224+ state.antennaChans = (snap.antenna ? snap.antenna.channels : snap.report.channels || [])225+ .map((c) => ({ ...c, key: `${c.number}|${c.guideCallSign || c.callSign}` }));226+ if (snap.antenna) applySignal(snap.antenna);227+ // The server's own channels, with the details set on its admin page. The228+ // weather channel plays here, from the WeatherStar display; the others'229+ // schedules join the guide like a station's, under their own guide id,230+ // their category counting as a genre of each program. A server from231+ // before the weather channel was listed here sends no list, or channels232+ // without a kind: then the weather channel is the old WX.233+ const listed = Array.isArray(snap.custom) && snap.custom.every((c) => c.kind);234+ state.custom = (snap.custom || []).map((c) => {235+ const ch = ownChannel(c);236+ if (!ch.weather) {237+ const genres = categoryGenres(ch.category);238+ state.guide.programs[ch.guideId] = (c.programs || []).map((p) => (genres.length ? { ...p, genres: [...new Set([...(p.genres || []), ...genres])] } : p));239+ }240+ return ch;241+ });242+ if (!listed && state.info.weatherStar && !state.custom.some((c) => c.weather)) state.custom.unshift(OLD_WX);243+ indexData();244+ await migrateKeys();245+ buildLineup();246+}247+248+function indexData() {249+ state.byFacility.clear();250+ state.bySite.clear();251+ const stations = [...state.report.stations].sort((a, b) => a.distanceKm - b.distanceKm);252+ for (const s of stations) {253+ if (!state.byFacility.has(s.facilityId)) state.byFacility.set(s.facilityId, s);254+ state.bySite.set(`${s.facilityId}:${s.rfChannel}`, s);255+ }256+ const progs = (state.guide && state.guide.programs) || {};257+ for (const list of Object.values(progs)) {258+ for (const p of list) { p._s = Date.parse(p.start); p._e = Date.parse(p.end); }259+ }260+}261+262+// ---------- the server's own channels ----------263+// Keys (for favorites and hidden channels) stay with a channel through a new264+// number or name: the weather channel has one key, and a custom channel's265+// carries its name as well as its number (migrateKeys follows either).266+const WEATHER_KEY = 'weather';267+268+// A custom channel's category counts as these genres for its programs, so269+// the guide's filters find it; categories with no filter of their own get270+// one while a channel has them (guideFilters).271+const CATEGORY_GENRES = {272+ Sports: ['sports'], Movies: ['movie'], News: ['news'], Family: ['family'], Kids: ['kids', 'family'],273+ Music: ['music'], Pets: ['pets'], Gaming: ['gaming'], Documentary: ['documentary'], Weather: ['weather'],274+};275+const categoryGenres = (category) => CATEGORY_GENRES[category] || [];276+277+// ownChannel is a channel the server lists as its own, for the lineup. Its278+// call sign is the short label (call); its name stands where a station's279+// network does.280+function ownChannel(c) {281+ const [major, minor] = c.number.split('.').map(Number);282+ const weather = c.kind === 'weather';283+ return {284+ key: weather ? WEATHER_KEY : `${c.number}|custom|${c.name}`,285+ number: c.number, major: major || 0, minor: minor || 0, name: c.name, network: c.name,286+ call: c.callSign || '', callSign: c.callSign || '', baseCall: c.callSign || '',287+ kind: c.kind || 'folder', category: c.category || 'Other', description: c.description || '',288+ logo: c.logo ? state.boot.serverUrl + c.logo : '',289+ own: true, weather, custom: !weather, guideId: weather ? '' : `custom:${c.number}`,290+ };291+}292+293+// OLD_WX is the weather channel of a server from before it listed it.294+const OLD_WX = {295+ key: WEATHER_KEY, number: 'WX', major: 0, minor: 0, name: 'Airwaves Weather', network: 'Local Forecast',296+ call: 'WX', callSign: 'WX', baseCall: 'WX', kind: 'weather', category: 'Weather', description: '', logo: '',297+ own: true, weather: true, custom: false, guideId: '',298+};299+300+const weatherChannel = () => state.custom.find((c) => c.weather) || null;301+302+// migrateKeys moves saved favorites and hidden channels to where the303+// server's own channels are now: the weather channel's from the old WX key,304+// and a custom channel's to its current number and name (found by name, or305+// else by number). An antenna channel's whose call sign changed moves to306+// the one channel with its number. Keys of channels not listed now are kept307+// for when they are. A saved last channel of WX becomes the weather308+// channel's number, and one no longer in use the new number of the custom309+// channel a saved key names with it.310+async function migrateKeys() {311+ const s = state.settings;312+ const custom = state.custom.filter((c) => !c.weather);313+ const known = new Set([...state.custom, ...state.antennaChans].map((c) => c.key));314+ const fix = (k) => {315+ if (k === 'WX|WX') return WEATHER_KEY;316+ if (known.has(k)) return k;317+ const [num, kind, ...rest] = String(k).split('|');318+ if (kind !== 'custom') {319+ const same = state.antennaChans.filter((c) => c.number === num);320+ return same.length === 1 && kind ? same[0].key : k;321+ }322+ const name = rest.join('|').toLowerCase();323+ const named = name ? custom.filter((c) => c.name.toLowerCase() === name) : [];324+ if (named.length === 1) return named[0].key;325+ const numbered = custom.find((c) => numberKey(c) === numberKey({ number: num }));326+ return numbered ? numbered.key : k;327+ };328+ const favorites = [...new Set((s.favorites || []).map(fix))];329+ const hidden = [...new Set((s.hidden || []).map(fix))];330+ const wx = weatherChannel();331+ let lastChannel = s.lastChannel;332+ const numbers = new Set([...state.custom, ...state.antennaChans].map((c) => c.number));333+ if (lastChannel === 'WX' && wx) lastChannel = wx.number;334+ else if (lastChannel && !numbers.has(lastChannel)) {335+ const old = [...(s.favorites || []), ...(s.hidden || [])].find((k) => k.startsWith(`${lastChannel}|custom|`));336+ const now = old && state.custom.find((c) => c.key === fix(old));337+ if (now) lastChannel = now.number;338+ }339+ const same = (a, b) => a.length === (b || []).length && a.every((k, i) => k === b[i]);340+ if (same(favorites, s.favorites) && same(hidden, s.hidden) && lastChannel === s.lastChannel) return;341+ log('info', 'settings: moved saved channels to their current numbers');342+ try {343+ await saveAppSettings({ ...s, favorites, hidden, lastChannel });344+ } catch (e) {345+ log('warn', `settings: ${e}`);346+ state.settings = { ...s, favorites, hidden, lastChannel };347+ }348+}349+350+const isFav = (ch) => !!ch && (state.settings.favorites || []).includes(ch.key);351+const isHidden = (ch) => !!ch && (state.settings.hidden || []).includes(ch.key);352+353+// numberKey is a channel number's value: "1.05" and "1.5" are one number.354+function numberKey(c) {355+ const [major, minor] = String(c.number).split('.').map((x) => parseInt(x, 10) || 0);356+ return `${major}.${minor}`;357+}358+const byNumber = (a, b) => (a.major - b.major) || (a.minor - b.minor);359+360+// buildLineup is the channels to show, by number: the server's own and the361+// tuner's, less those hidden. A tuner's channel stays when its signal is362+// weak now; tuning it then says so. A custom channel takes the place of an363+// antenna channel with its number, as it does on the server, which plays it364+// for that number.365+function buildLineup() {366+ const taken = new Set(state.custom.map(numberKey));367+ const antenna = state.antennaChans.filter((c) => !taken.has(numberKey(c)) && !isHidden(c));368+ state.lineup = [...state.custom.filter((c) => !isHidden(c)), ...antenna].sort(byNumber);369+ if (state.current) state.current = state.lineup.find((c) => c.key === state.current.key) || state.current;370+}371+372+const programsFor = (ch) => (ch && ch.weather ? wxPrograms() : (ch && ch.guideId && state.guide && state.guide.programs[ch.guideId]) || []);373+const airingAt = (ch, t) => programsFor(ch).find((p) => p._s <= t && t < p._e);374+const nextAfter = (ch, t) => programsFor(ch).find((p) => p._s > t);375+// stationFor is the licensed transmitter a channel comes from, on its RF376+// channel.377+const stationFor = (ch) => ch && (state.bySite.get(`${ch.facilityId}:${ch.rf}`) || state.byFacility.get(ch.facilityId));378+// sigFor is what was measured of a channel's RF channel.379+const sigFor = (ch) => (ch && !ch.own ? { signal: ch.signal, recent: ch.recent } : null);380+// nextGen reports whether the tuners take in ATSC 3.0; without, NextGen381+// viewing (its guide filter) stays out of sight.382+const nextGen = () => !!(state.info && state.info.tuner && state.info.tuner.atsc3);383+// displayCall is a channel's short label: a station's call sign, or an own384+// channel's call sign, "Airwaves" when it has none.385+const displayCall = (ch) => (ch && ch.own ? ch.call || 'Airwaves' : (ch && (ch.baseCall || ch.callSign)) || '');386+387+function findChannel(number) {388+ if (!number) return null;389+ const n = number;390+ return state.lineup.find((c) => c.number === n)391+ || state.lineup.find((c) => c.major === Number(n) && (c.minor === 1 || c.minor === 0))392+ || state.antennaChans.find((c) => c.number === n)393+ || state.custom.find((c) => c.number === n);394+}395+396+// ---------- boot ----------397+function bootLog(msg, err) {398+ const li = document.createElement('li');399+ li.textContent = msg;400+ if (err) li.className = 'err';401+ const ol = $('.boot-log');402+ ol.append(li);403+ while (ol.children.length > 7) ol.firstChild.remove();404+}405+406+async function init() {407+ wireKeys();408+ wireOSD();409+ wireDock();410+ wireMouse();411+ wireGuide();412+ wireAntenna();413+ wireSettings();414+ wireRecordings();415+ wireWeather();416+ wirePads();417+ wireMediaSession();418+ renderHints();419+ // Keys work from launch, without a click first.420+ window.focus();421+ if (document.activeElement && document.activeElement !== document.body && !document.activeElement.matches(TEXT_INPUT)) document.activeElement.blur();422+ if (!api()) {423+ bootLog('Wails runtime not found. Run this page inside the Airwaves app.', true);424+ return;425+ }426+ window.runtime.EventsOn('progress', (msg) => {427+ if (document.body.classList.contains('booting')) bootLog(msg);428+ });429+ applyBoot(await api().Boot());430+ if (!state.settings.server) return showConnect('');431+ f($('#boot'), 'sub').textContent = `Connecting to ${state.settings.server}`;432+ try {433+ if (state.boot.error) throw new Error(state.boot.error);434+ await scan(false);435+ } catch (e) {436+ return showConnect(String(e && e.message ? e.message : e));437+ }438+ f($('#boot'), 'sub').textContent = state.info.name;439+ for (const w of state.report.warnings || []) bootLog(w, true);440+ bootLog(`Ready: ${state.lineup.length} channels`);441+ setTimeout(() => document.body.classList.remove('booting'), 700);442+ // Gone once faded out, so its title stops animating.443+ setTimeout(() => { $('#boot').hidden = true; }, 700 + 1200);444+445+ renderAntenna();446+ renderSettings();447+ const view = state.boot.view;448+ const start = findChannel(state.settings.lastChannel) || state.lineup.find((c) => c.major >= 2) || state.lineup[0];449+ if (state.info.playback) toast(`Playback unavailable: ${state.info.playback}`, 8000);450+ if (start) tune(start, { quiet: !!view && view !== 'tv' });451+ if (view === 'info') {452+ // Pinned banner, for screenshots.453+ showBanner();454+ clearTimeout(state.bannerTimer);455+ } else if (view) setView(view);456+ setInterval(tick, 30_000);457+ setInterval(() => scan(false).then(renderAll).catch((e) => log('warn', e)), 30 * MIN);458+ let dvrAt = Date.now();459+ setInterval(() => {460+ if (state.view === 'guide' || state.view === 'recordings' || Date.now() - dvrAt > 5 * MIN - 1000) {461+ dvrAt = Date.now();462+ loadDVR();463+ }464+ }, MIN);465+ setInterval(heartbeat, 30_000);466+ setInterval(loadWeather, 2 * MIN);467+ setInterval(signalTick, 2000);468+ loadDVR();469+ loadWeather();470+}471+472+document.addEventListener('DOMContentLoaded', init);473+474+// showConnect asks for the server address on first launch or when the475+// configured server cannot be reached.476+function showConnect(err) {477+ const form = $('.boot-connect');478+ f($('#boot'), 'sub').textContent = 'Connect to your Airwaves server';479+ if (err) bootLog(err, true);480+ form.elements.server.value = state.settings.server || '';481+ form.elements.token.value = state.settings.token || '';482+ form.hidden = false;483+ // In a browser the server is the page's own (web.js): only a token to give.484+ const fixed = !!(state.boot && state.boot.fixedServer);485+ form.elements.server.readOnly = fixed;486+ form.elements.server.parentElement.classList.toggle('fx', !fixed);487+ bootField(form.elements[fixed ? 'token' : 'server'].parentElement);488+ f($('#boot'), 'hint').innerHTML = hints([['Arrows', 'Arrows', 'move'], ['Enter', 'Enter', androidTV ? 'types, or connects' : 'connects']]);489+ form.onsubmit = async (e) => {490+ e.preventDefault();491+ const server = form.elements.server.value.trim();492+ if (!server) return;493+ bootLog(`Connecting to ${server}`);494+ const b = await api().SaveSettings({ ...state.settings, server, token: form.elements.token.value });495+ if (b.error) return bootLog(b.error, true);496+ location.reload();497+ };498+}499+500+function applyBoot(b) {501+ state.boot = b;502+ state.settings = b.settings;503+ state.info = b.info || { name: '', tuner: {}, dvr: false };504+ state.config = b.config || {};505+ document.body.classList.toggle('has-dvr', !!state.info.dvr);506+ if (b.lite === '1' || b.lite === '0') setLite(b.lite === '1');507+ applyPlatform(b.platform || (androidTV ? 'tv' : 'desktop'));508+ applyScale();509+ renderHints();510+}511+512+// ---------- recordings ----------513+async function loadDVR() {514+ if (!state.info || !state.info.dvr) return;515+ try {516+ state.dvr = await api().DVR();517+ } catch (e) {518+ log('warn', `dvr: ${e}`);519+ return;520+ }521+ state.dvrKeys = new Map((state.dvr.upcoming || []).map((it) => [it.key, it]));522+ if (state.view === 'guide') renderGuide();523+ if (state.view === 'recordings') {524+ renderRecordings();525+ seeFailed();526+ }527+ renderFailed();528+}529+530+// ---------- failed recordings ----------531+// A recording that failed puts a count on Recordings in the dock, and says532+// so once, until Recordings is opened.533+const failedSeenKey = 'airwaves.failedSeen';534+const failedTold = new Set();535+const failedRecordings = () => ((state.dvr && state.dvr.recorded) || []).filter((it) => it.status === 'failed');536+537+function renderFailed() {538+ const b = $('#dock [data-view="recordings"]');539+ const seen = new Set(JSON.parse(localStorage.getItem(failedSeenKey) || '[]'));540+ const unseen = failedRecordings().filter((it) => !seen.has(it.id));541+ let badge = $('.badge', b);542+ if (!unseen.length) {543+ if (badge) badge.remove();544+ return;545+ }546+ if (!badge) {547+ badge = document.createElement('span');548+ badge.className = 'badge';549+ b.append(badge);550+ }551+ badge.textContent = String(unseen.length);552+ badge.title = `${unseen.length} recording${unseen.length > 1 ? 's' : ''} failed`;553+ const news = unseen.filter((it) => !failedTold.has(it.id));554+ for (const it of news) failedTold.add(it.id);555+ if (news.length === 1) toast(`Recording failed: ${news[0].title}${news[0].detail ? `. ${news[0].detail}` : ''}`, 7000);556+ else if (news.length) toast(`${news.length} recordings failed. Recordings has them.`, 7000);557+}558+559+function seeFailed() {560+ localStorage.setItem(failedSeenKey, JSON.stringify(failedRecordings().map((it) => it.id)));561+ renderFailed();562+}563+564+const airingKey = (ch, p) => `${ch.number}@${Math.floor(p._s / 1000)}`;565+566+function seriesRuleFor(ch, p) {567+ return p && p.seriesId && state.dvr && (state.dvr.rules || []).find((r) => r.kind === 'series' && r.seriesId === p.seriesId && r.channel === ch.number);568+}569+570+function renderAll() {571+ renderBanner();572+ if (state.view === 'guide') renderGuide();573+ renderAntenna();574+ renderSettings();575+}576+577+function tick() {578+ renderBanner();579+ if (state.view === 'guide') renderGuide();580+ applyCaptions(); // a new program may want them otherwise581+}582+583+// ---------- views ----------584+function setView(v) {585+ if (state.dock >= 0) closeDock(false);586+ if (v === state.view) return restoreFocus();587+ const prev = state.view;588+ // The banner and the controls go with TV (their timers with them).589+ if (prev === 'tv') hideBanner();590+ // The focus starts over in the new view.591+ blurIn(document.body);592+ state.view = v;593+ document.body.classList.remove(`mode-${prev}`);594+ document.body.classList.add(`mode-${v}`);595+ $$('#dock button').forEach((b) => b.classList.toggle('on', b.dataset.view === v));596+ if (v === 'guide') { openGuide(); loadDVR(); }597+ if (v === 'recordings') { openRecordings(); loadDVR(); }598+ if (v === 'weather') {599+ renderWeather();600+ wxUI.at = 0;601+ wxFocus();602+ loadWeather();603+ if (state.info.weatherStar && !(state.current && state.current.weather)) {604+ state.wxPreview = true;605+ video.muted = true;606+ showWX(true, true);607+ }608+ }609+ if (prev === 'weather' && state.wxPreview) {610+ state.wxPreview = false;611+ video.muted = !!state.userMuted;612+ if (!(state.current && state.current.weather)) showWX(false);613+ }614+ if (v === 'settings') openSettings();615+ if (v === 'tv' && state.staleStream) restartStream();616+ if (v === 'recordings') seeFailed();617+ return undefined;618+}619+620+function wireDock() {621+ $$('#dock button').forEach((b) => b.addEventListener('click', () => setView(b.dataset.view)));622+ $('#dock button[data-view="tv"]').classList.add('on');623+ stage.addEventListener('click', () => {624+ if (state.view === 'weather' && state.wxPreview) tune(weatherChannel());625+ if (state.view !== 'tv') setView('tv');626+ });627+ stage.addEventListener('dblclick', () => { if (state.view === 'tv') toggleFullscreen(); });628+}629+630+let mouseTimer = 0;631+function wireMouse() {632+ // WebKitGTK focuses a button when it is clicked, and Enter or Space then633+ // pressed it again besides doing what the key does here. Clicks leave634+ // the focus where it was, as on a Mac.635+ document.addEventListener('mousedown', (e) => {636+ if (!e.target.closest(TEXT_INPUT) && e.target.closest('button, .fx')) e.preventDefault();637+ });638+ // Only a pointer that really moved counts. WebKit also sends moves (and639+ // mouseenter) when content appears under a pointer left still, as on a640+ // TV where it rests wherever it was; the banner then never hid.641+ document.addEventListener('mousemove', (e) => {642+ if (!e.movementX && !e.movementY) return;643+ document.body.classList.add('mouse-active');644+ clearTimeout(mouseTimer);645+ mouseTimer = setTimeout(() => document.body.classList.remove('mouse-active'), 2500);646+ // The controls, for a pointer near the bottom; no focus mark.647+ if (state.view === 'tv' && e.clientY > window.innerHeight * 0.55) openOSD(null);648+ });649+}650+651+async function toggleFullscreen() {652+ const rt = window.runtime;653+ if (!rt) return;654+ if (await rt.WindowIsFullscreen()) rt.WindowUnfullscreen(); else rt.WindowFullscreen();655+}
A frontend/dist/js/guide.js +542 -0
@@ -0,0 +1,542 @@
1+'use strict';2+3+// The guide.4+5+// ---------- guide ----------6+const FILTERS = [7+ ['all', 'All'],8+ ['favorites', 'Favorites'],9+ ['nextgen', 'NextGen'],10+ ['sports', 'Sports'],11+ ['movie', 'Movies'],12+ ['news', 'News'],13+ ['family', 'Family'],14+];15+16+// guideFilters adds a filter for each category of the lineup's own channels17+// that none of FILTERS covers ("Kids", "Gaming"), in the order categories18+// are listed on the admin page. Not Weather: the weather channel leads the19+// guide anyway. NextGen only with a tuner that takes in ATSC 3.0.20+function guideFilters() {21+ const base = FILTERS.filter(([k]) => k !== 'nextgen' || nextGen());22+ const keys = new Set([...base.map(([k]) => k), 'weather']);23+ const extra = [];24+ for (const category of Object.keys(CATEGORY_GENRES)) {25+ const k = categoryGenres(category)[0];26+ if (keys.has(k) || !state.lineup.some((c) => c.own && c.category === category)) continue;27+ keys.add(k);28+ extra.push([k, category]);29+ }30+ return [...base, ...extra];31+}32+33+function guideChannels() {34+ const lo = state.gStart;35+ const hi = state.gStart + state.gSpan * MIN;36+ switch (state.guideFilter) {37+ case 'all': return state.lineup;38+ case 'favorites': return state.lineup.filter(isFav);39+ case 'nextgen': return state.lineup.filter((c) => c.atsc3);40+ default:41+ return state.lineup.filter((c) => programsFor(c).some((p) => p._e > lo && p._s < hi && (p.genres || []).includes(state.guideFilter)));42+ }43+}44+45+function openGuide() {46+ const now = Date.now();47+ state.gStart = floor30(now);48+ state.gTime = now;49+ guideZone('grid');50+ const list = guideChannels();51+ state.gRow = Math.max(0, list.findIndex((c) => state.current && c.key === state.current.key));52+ renderGuide();53+ scrollRowIntoView(true);54+}55+56+// The guide's parts take the focus in turn (data-zone): the grid; the57+// filters above it, Up from its first row; and the selected program's58+// actions in the detail (Watch, Record, Series, New only, Favorite, Hide),59+// which OK on a program to come brings up, as holding OK or I does on any60+// (Kodi's and Android TV's long press for more). OK on what's on now61+// watches it, as in the Live TV app's guide.62+const guideUI = { zone: 'grid', act: '' };63+64+function guideZone(zone, act = '') {65+ guideUI.zone = zone;66+ $('#guide').dataset.zone = zone;67+ if (act) guideUI.act = act;68+ if (state.dock >= 0 || state.view !== 'guide') return;69+ if (zone === 'grid') return blurIn($('#guide'));70+ if (zone === 'filters') {71+ const btns = $$('.g-filters button');72+ focusEl(btns.find((b) => b.dataset.filter === state.guideFilter) || btns[0]);73+ return;74+ }75+ const acts = guideActs();76+ if (!acts.length) return guideZone('grid');77+ focusEl(acts.find((b) => b.dataset.act === guideUI.act) || acts[0]);78+}79+80+const guideActs = () => $$('#guide .gd-actions .act').filter(isShown);81+82+// guideRefocus puts the focus back on the filters or actions after they83+// were drawn again.84+function guideRefocus() {85+ if (state.view !== 'guide' || state.dock >= 0 || guideUI.zone === 'grid') return;86+ if (!$('#guide').contains(document.activeElement)) guideZone(guideUI.zone);87+}88+89+function guideOpenActions() {90+ if (state.view === 'guide' && guideChannels()[state.gRow]) guideZone('actions', '-');91+}92+93+function wireGuide() {94+ const actions = f($('#guide'), 'actions');95+ actions.addEventListener('click', (e) => {96+ const b = e.target.closest('[data-act]');97+ if (!b) return;98+ const list = guideChannels();99+ const ch = list[state.gRow];100+ switch (b.dataset.act) {101+ case 'watch': return watchChannel(ch);102+ case 'record': return openRecordPick(ch, selectedProgram(list));103+ case 'once': state.recPick = ''; return recordSelected('once');104+ case 'series': state.recPick = ''; return recordSelected('series', false);105+ case 'new': state.recPick = ''; return recordSelected('series', true);106+ case 'fav': return toggleFavorite(ch);107+ case 'hide': guideZone('grid'); return hideChannel(ch);108+ default: return null;109+ }110+ });111+ actions.addEventListener('focusin', (e) => {112+ const b = e.target.closest('[data-act]');113+ if (b) guideUI.act = b.dataset.act;114+ });115+ const rows = $('.g-rows');116+ rows.addEventListener('click', (e) => {117+ const cell = e.target.closest('[data-row]');118+ if (!cell) return;119+ guideZone('grid');120+ state.gRow = Number(cell.dataset.row);121+ if (cell.dataset.t) state.gTime = Number(cell.dataset.t);122+ renderGuide();123+ });124+ rows.addEventListener('dblclick', (e) => {125+ const cell = e.target.closest('[data-row]');126+ if (cell) guideActivate();127+ });128+}129+130+function guideKey(e) {131+ if (guideUI.zone === 'filters' && guideFiltersKey(e)) return;132+ if (guideUI.zone === 'actions' && guideActionsKey(e)) return;133+ const list = guideChannels();134+ const sel = selectedProgram(list);135+ switch (e.key) {136+ // Up and Down wrap round the channels, so the last ones are a press137+ // away. The dock is Left from the earliest program, and the filters138+ // Down from the dock.139+ case 'ArrowUp': state.gRow = list.length ? (state.gRow - 1 + list.length) % list.length : 0; break;140+ case 'ArrowDown': state.gRow = list.length ? (state.gRow + 1) % list.length : 0; break;141+ case 'PageUp': state.gRow = state.gRow === 0 ? list.length - 1 : Math.max(0, state.gRow - 8); break;142+ case 'PageDown': state.gRow = state.gRow === list.length - 1 ? 0 : Math.min(list.length - 1, state.gRow + 8); break;143+ case 'ArrowRight':144+ state.gTime = sel ? sel._e : state.gTime + 30 * MIN;145+ while (state.gTime >= state.gStart + (state.gSpan - 30) * MIN) state.gStart += 30 * MIN;146+ break;147+ case 'ArrowLeft': {148+ const min = floor30(Date.now()) - 30 * MIN;149+ // From the earliest program, Left moves on to the dock.150+ if (!sel || sel._s <= min) { e.preventDefault(); return openDock(); }151+ state.gTime = Math.max(min, (sel ? sel._s : state.gTime) - 1);152+ while (state.gTime < state.gStart && state.gStart > min) state.gStart -= 30 * MIN;153+ break;154+ }155+ case 'Home': state.gStart = floor30(Date.now()); state.gTime = Date.now(); break;156+ case 'Enter':157+ e.preventDefault();158+ // A key's press acts on its keyup, a hold brings up the actions; a159+ // controller's A, at once.160+ if (e.isTrusted) return holdStart(() => { if (state.view === 'guide' && guideUI.zone === 'grid') guideActivate(); }, guideOpenActions);161+ return guideActivate();162+ case 'i': case 'I': e.preventDefault(); return guideOpenActions();163+ case 'r': e.preventDefault(); recordSelected('once'); return;164+ case 'F': e.preventDefault(); toggleFavorite(list[state.gRow]); return;165+ case 'h': case 'H': e.preventDefault(); hideChannel(list[state.gRow]); return;166+ case 'R': e.preventDefault(); recordSelected('series', false); return;167+ case 'n': case 'N': e.preventDefault(); recordSelected('series', true); return;168+ default: {169+ if (e.key === ']' || e.key === '[') {170+ const filters = guideFilters();171+ const i = filters.findIndex(([k]) => k === state.guideFilter);172+ state.guideFilter = filters[(i + (e.key === ']' ? 1 : filters.length - 1)) % filters.length][0];173+ state.gRow = 0;174+ break;175+ }176+ return;177+ }178+ }179+ e.preventDefault();180+ renderGuide();181+ scrollRowIntoView();182+}183+184+// guideFiltersKey: Left and Right pick a filter (as a tab does), Down or OK185+// goes to the grid, Up (or Left from the first) to the dock.186+function guideFiltersKey(e) {187+ const btns = $$('.g-filters button');188+ const i = Math.max(0, btns.findIndex((b) => b.dataset.filter === state.guideFilter));189+ switch (e.key) {190+ case 'ArrowLeft': case 'ArrowRight': {191+ const j = i + (e.key === 'ArrowLeft' ? -1 : 1);192+ if (j < 0) { openDock(); break; }193+ if (j >= btns.length) break;194+ state.guideFilter = btns[j].dataset.filter;195+ state.gRow = 0;196+ renderGuide();197+ $('.g-rows').scrollTop = 0;198+ break;199+ }200+ case 'ArrowUp': openDock(); break;201+ case 'ArrowDown': case 'Enter': guideZone('grid'); break;202+ default: return false;203+ }204+ e.preventDefault();205+ return true;206+}207+208+// guideActionsKey: Left and Right along the actions, OK presses, Down (or209+// Back) goes back to the grid, Up to the dock.210+function guideActionsKey(e) {211+ const acts = guideActs();212+ const el = document.activeElement;213+ switch (e.key) {214+ case 'ArrowLeft': case 'ArrowRight': focusEl(neighbor(acts, el, e.key === 'ArrowLeft' ? -1 : 1) || (acts.includes(el) ? null : acts[0])); break;215+ case 'ArrowUp': openDock(); break;216+ case 'ArrowDown': guideZone('grid'); break;217+ case 'Enter':218+ if (acts.includes(el)) el.click(); else guideZone('actions');219+ break;220+ default: return false;221+ }222+ e.preventDefault();223+ return true;224+}225+226+// guideActivate is OK on a program: what's on now (or was, a little227+// before) is watched on its channel, and a program to come shows what can228+// be done with it.229+function guideActivate() {230+ const list = guideChannels();231+ const ch = list[state.gRow];232+ if (!ch) return;233+ const p = selectedProgram(list);234+ if (!p || p._s <= Date.now()) watchChannel(ch);235+ else guideOpenActions();236+}237+238+// watchChannel goes back to TV on a channel. Choosing what's already239+// playing goes back to it without tuning again (and losing the rewind240+// buffer); a failed channel retries.241+function watchChannel(ch) {242+ if (!ch) return;243+ const playing = state.current && state.current.key === ch.key && !state.recording && $('#nosignal').hidden;244+ if (!playing) tune(ch);245+ setView('tv');246+ if (playing) showBanner();247+}248+249+function selectedProgram(list) {250+ const ch = list[state.gRow];251+ if (!ch) return null;252+ const t = Math.max(state.gTime, state.gStart);253+ return airingAt(ch, t) || null;254+}255+256+function scrollRowIntoView(center) {257+ const rows = $('.g-rows');258+ const rowH = 58;259+ const top = state.gRow * rowH;260+ if (center) rows.scrollTop = top - rows.clientHeight / 2 + rowH;261+ else if (top < rows.scrollTop) rows.scrollTop = top;262+ else if (top + rowH > rows.scrollTop + rows.clientHeight) rows.scrollTop = top + rowH - rows.clientHeight;263+}264+265+// guideShown is what the guide's rows were last built from. They are built266+// again only when that changes; moving the selection moves two classes, as267+// rebuilding thousands of cells on every key is slow on a TV box.268+let guideShown = [];269+270+function renderGuide() {271+ if (!state.report) return;272+ const g = $('#guide');273+ const list = guideChannels();274+ state.gRow = Math.min(state.gRow, Math.max(0, list.length - 1));275+ const rows = $('.g-rows');276+ const lo = state.gStart;277+ const now = Date.now();278+ const shown = [state.guideFilter, lo, rows.clientWidth, Math.floor(now / MIN), state.lineup, state.guide, state.dvrKeys, state.settings, state.current && state.current.key, state.wx, state.sigStamp];279+ if (shown.every((v, i) => v === guideShown[i])) return moveGuideSelection(list, lo);280+ guideShown = shown;281+ const chw = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--chw')) || 260;282+ const width = Math.max(300, rows.clientWidth - chw);283+ const ppm = width / state.gSpan;284+ const hi = lo + state.gSpan * MIN;285+286+ // filters287+ const filters = guideFilters();288+ if (!filters.some(([k]) => k === state.guideFilter)) state.guideFilter = 'all';289+ $('.g-filters').innerHTML = filters.map(([k, label]) => {290+ const saved = state.guideFilter;291+ state.guideFilter = k;292+ const n = guideChannels().length;293+ state.guideFilter = saved;294+ return `<button type="button" role="tab" aria-selected="${k === state.guideFilter}" data-filter="${k}" class="fx${k === state.guideFilter ? ' on' : ''}">${label}<span class="n">${n}</span></button>`;295+ }).join('');296+ $$('.g-filters button').forEach((b) => b.addEventListener('click', () => {297+ state.guideFilter = b.dataset.filter;298+ state.gRow = 0;299+ renderGuide();300+ }));301+302+ // time header303+ const d = new Date(lo);304+ const today = new Date(now).toDateString() === d.toDateString();305+ f(g, 'day').textContent = today ? 'Today' : d.toLocaleDateString([], { weekday: 'long' });306+ let ticks = '';307+ for (let t = lo; t < hi; t += 30 * MIN) ticks += `<span style="left:${(t - lo) / MIN * ppm}px">${clock(t)}</span>`;308+ $('.g-ticks').innerHTML = ticks;309+310+ // rows311+ const sel = selectedProgram(list);312+ let html = '';313+ list.forEach((ch, r) => {314+ // A channel's measured signal shows only when it explains a bad picture.315+ const sig = ch.own ? null : sigOf(sigFor(ch));316+ const bad = sig && trouble(sig);317+ const cls = ['g-row', r === state.gRow ? 'sel' : '', state.current && ch.key === state.current.key ? 'cur' : ''].join(' ');318+ html += `<div class="${cls}"><div class="g-ch" data-row="${r}">319+ <div class="num${ch.number.length > 4 ? ' long' : ''}">${esc(ch.number)}</div>320+ <div class="who"><div class="net">${isFav(ch) ? '<span class="fav">★</span>' : ''}${esc(ch.network || displayCall(ch))}</div><div class="call"${bad ? ` title="${esc(`${sig.label}: ${sigDetail(sig)}`)}"` : ''}>${bad ? meter(sig) : ''}<span>${esc(displayCall(ch))}</span></div></div>321+ ${ch.logoDark ? `<div class="logo own" style="background-image:url('${esc(ch.logoDark)}')"></div>`322+ : ch.logo ? `<div class="logo${ch.own ? ' own' : ''}" style="background-image:url('${esc(ch.logo)}')"></div>` : '<div></div>'}323+ </div><div class="g-progs">`;324+ const progs = programsFor(ch).filter((p) => p._e > lo && p._s < hi);325+ if (!progs.length) {326+ const label = ch.weather ? 'Local forecast, around the clock' : ch.custom ? ch.description || 'Nothing scheduled yet' : 'No listings';327+ html += `<div class="g-cell empty${r === state.gRow ? ' sel' : ''}" data-row="${r}" style="left:2px;width:${width - 4}px"><div class="t">${label}</div></div>`;328+ }329+ for (const p of progs) {330+ const left = Math.max(0, (p._s - lo) / MIN * ppm);331+ const right = Math.min(width, (p._e - lo) / MIN * ppm);332+ const w = Math.max(0, right - left - 3);333+ const isSel = r === state.gRow && sel === p;334+ const rec = state.dvrKeys.get(airingKey(ch, p));335+ const recCls = rec ? (rec.status === 'unavailable' || rec.status === 'conflict' ? 'rec rec-x' : rec.status === 'recording' ? 'rec rec-on' : 'rec') : '';336+ const c = ['g-cell', p._e <= now ? 'past' : '', p._s <= now && now < p._e ? 'now' : '', isSel ? 'sel' : '', recCls].join(' ');337+ const tag = (p.flags || []).includes('Live') ? '<span class="tag">LIVE </span>' : (p.flags || []).includes('New') ? '<span class="tag">NEW </span>' : '';338+ const sub = p.episodeTitle || `${clock(p._s)} to ${clock(p._e)}`;339+ html += `<div class="${c}" data-row="${r}" data-t="${Math.max(p._s, lo)}" style="left:${left + 2}px;width:${w}px"><div class="t">${tag}${esc(p.title)}</div>${w > 90 ? `<div class="s">${esc(sub)}</div>` : ''}</div>`;340+ }341+ html += '</div></div>';342+ });343+ const keep = rows.scrollTop;344+ rows.innerHTML = html || '<div style="padding:28px;color:var(--muted)">No channels match this filter.</div>';345+ rows.scrollTop = keep;346+347+ // now line348+ const nl = $('.g-now');349+ if (now >= lo && now < hi) {350+ nl.style.display = '';351+ nl.style.left = `${chw + (now - lo) / MIN * ppm}px`;352+ } else nl.style.display = 'none';353+354+ renderGuideDetail(list[state.gRow], sel);355+ f(g, 'status').innerHTML = `${list.length} channels | ${guideStatusHints()}`;356+ guideRefocus();357+}358+359+// guideStatusHints names the guide's keys beside its filters.360+function guideStatusHints() {361+ if (onRemote()) return 'Filters: Left to the top bar, then Down';362+ return hints([['i', 'I', 'more'], ['', 'Shift F', 'favorite'], ['h', 'H', 'hide'], ['[', '[ ]', 'filter']]);363+}364+365+// moveGuideSelection marks the selected row and program in rows already366+// built, as renderGuide would.367+function moveGuideSelection(list, lo) {368+ const rows = $('.g-rows');369+ const sel = selectedProgram(list);370+ for (const el of rows.querySelectorAll('.sel')) el.classList.remove('sel');371+ const row = list.length ? rows.children[state.gRow] : null;372+ if (row) {373+ row.classList.add('sel');374+ const cell = sel ? row.querySelector(`.g-cell[data-t="${Math.max(sel._s, lo)}"]`) : row.querySelector('.g-cell.empty');375+ if (cell) cell.classList.add('sel');376+ }377+ renderGuideDetail(list[state.gRow], sel);378+}379+380+function humanMinutes(m) {381+ if (m < 60) return `${m} min`;382+ const h = Math.floor(m / 60);383+ return m % 60 ? `${h} h ${m % 60} min` : `${h} h`;384+}385+386+// renderGuideActions draws the selected program's actions: Watch first for387+// what's on now, the recording ones where recording applies (the388+// antenna's channels, on a server with a DVR), then the channel's.389+function renderGuideActions(ch, p) {390+ const box = f($('#guide'), 'actions');391+ if (!ch) { box.innerHTML = ''; return; }392+ // Laid out, it may need less shown beside it.393+ requestAnimationFrame(fitGuideDetail);394+ const now = Date.now();395+ const airing = !p || p._s <= now;396+ const act = (name, label, cls = '') => `<button type="button" class="act fx${cls ? ` ${cls}` : ''}" data-act="${name}">${label}</button>`;397+ const parts = [];398+ if (airing) parts.push(act('watch', !p || now < p._e ? 'Watch' : `Watch ${esc(ch.number)}`));399+ if (state.info.dvr && p && p._e > now && !ch.weather && !ch.custom) {400+ const it = state.dvrKeys.get(airingKey(ch, p));401+ const rule = seriesRuleFor(ch, p);402+ if (it) {403+ const label = { recording: 'Recording now', unavailable: 'Channel not available', conflict: 'Conflict: every tuner records then' }[it.status] || 'Recording scheduled';404+ parts.push(`<span class="chip ${it.status === 'unavailable' || it.status === 'conflict' ? 'warn' : 'live'}">${label}</span>`);405+ parts.push(act('once', it.status === 'recording' ? 'Stop recording' : 'Cancel'));406+ } else if (!rule && p.seriesId) {407+ // One Record, which opens to this airing, every episode or new ones;408+ // another program closes it.409+ if (state.recPick !== airingKey(ch, p)) state.recPick = '';410+ parts.push(state.recPick === airingKey(ch, p)411+ ? act('once', '<i></i>This one', 'rec') + act('series', 'Every episode') + act('new', 'New only')412+ : act('record', '<i></i>Record', 'rec'));413+ } else {414+ parts.push(act('once', '<i></i>Record', 'rec'));415+ }416+ if (rule) {417+ parts.push(`<span class="chip new">Series${rule.newOnly ? ', new only' : ''}</span>${act('series', 'Stop series')}`);418+ }419+ }420+ if (!airing) parts.push(act('watch', `Watch ${esc(ch.number)}`));421+ parts.push(act('fav', isFav(ch) ? 'Unfavorite' : 'Favorite'));422+ parts.push(act('hide', 'Hide channel'));423+ box.innerHTML = parts.join('');424+ guideRefocus();425+}426+427+// fitGuideDetail leaves out what of the program's details doesn't fit428+// above the grid at this window and interface size: its cast first, then429+// its description, its tags, its episode line and the hint. The title and the430+// actions always show, the actions on one row that scrolls sideways when431+// even that isn't enough.432+function fitGuideDetail() {433+ const g = $('#guide');434+ const top = $('.g-top', g);435+ const box = $('.g-detail', g);436+ // The picture needs the details to keep a readable width beside it.437+ top.classList.toggle('no-art', top.clientWidth < 1100);438+ const parts = ['cast', 'desc', 'meta', 'ep', 'hint'].map((k) => f(g, k));439+ for (const el of parts) el.classList.remove('gd-gone');440+ box.classList.remove('one-row');441+ const cs = getComputedStyle(top);442+ const room = top.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);443+ for (const el of parts) {444+ if (box.offsetHeight <= room) return;445+ el.classList.add('gd-gone');446+ }447+ if (box.offsetHeight > room) box.classList.add('one-row');448+}449+450+// openRecordPick opens Record into its three choices, the first focused.451+function openRecordPick(ch, p) {452+ if (!ch || !p) return;453+ state.recPick = airingKey(ch, p);454+ renderGuideActions(ch, p);455+ focusEl(f($('#guide'), 'actions').querySelector('[data-act="once"]'));456+}457+458+// closeRecordPick folds the choices back into Record, focused.459+function closeRecordPick() {460+ state.recPick = '';461+ const list = guideChannels();462+ renderGuideActions(list[state.gRow], selectedProgram(list));463+ const rec = f($('#guide'), 'actions').querySelector('[data-act="record"]');464+ if (rec && guideUI.zone === 'actions') focusEl(rec);465+}466+467+function recordSelected(kind, newOnly) {468+ const list = guideChannels();469+ return recordAiring(list[state.gRow], selectedProgram(list), kind, newOnly);470+}471+472+// recordNow records what is on the channel being watched.473+function recordNow(kind, newOnly) {474+ const ch = state.current;475+ if (state.recording || !ch) return;476+ const p = airingAt(ch, Date.now());477+ if (!p) return toast('No listing to record on this channel');478+ return recordAiring(ch, p, kind, newOnly).then(renderBanner);479+}480+481+async function recordAiring(ch, p, kind, newOnly) {482+ if (!state.info.dvr) return toast('Recording is not available on this server');483+ if (!ch || !p) return;484+ if (ch.weather) return toast('The weather channel is always on; nothing to record');485+ if (ch.custom) return toast(`${ch.name} is made by your server; nothing to record`);486+ if (p._e <= Date.now()) return toast('That has already aired');487+ const it = state.dvrKeys.get(airingKey(ch, p));488+ const rule = seriesRuleFor(ch, p);489+ try {490+ if (kind === 'once' && it && it.status === 'recording') {491+ await api().StopRecording(it.id);492+ toast(`Stopped recording ${p.title}; what was recorded is in the library`);493+ } else if (kind === 'once' && it) {494+ await api().DeleteRecording(it.id);495+ toast(`Won't record ${p.title}`);496+ } else if (kind === 'series' && rule) {497+ await api().DeleteRule(rule.id);498+ toast(`Stopped the series recording of ${p.title}`);499+ } else if (kind === 'series' && !p.seriesId) {500+ return toast(`${p.title} has no series information. Press R to record this airing.`);501+ } else {502+ await api().Record({ kind, channel: ch.number, callSign: ch.callSign, start: p.start, newOnly: !!newOnly });503+ const aired = Math.round((Date.now() - p._s) / MIN);504+ toast(kind === 'series'505+ ? `Recording ${newOnly ? 'new episodes' : 'every episode'} of ${p.title} on ${ch.number}`506+ : aired >= 1 ? `Recording ${p.title} from now; the first ${aired} minute${aired > 1 ? 's' : ''} already aired`507+ : `Recording ${p.title} at ${clock(p._s)} on ${ch.number}`);508+ }509+ } catch (e) {510+ toast(String(e && e.message ? e.message : e), 6000);511+ }512+ await loadDVR();513+}514+515+function renderGuideDetail(ch, p) {516+ const g = $('#guide');517+ if (!ch) {518+ // No channels under this filter.519+ for (const k of ['kicker', 'title', 'ep', 'meta', 'cast', 'desc', 'actions', 'hint']) f(g, k).textContent = '';520+ f(g, 'art').classList.remove('has');521+ return;522+ }523+ const now = Date.now();524+ f(g, 'kicker').innerHTML = `<span>${esc(ch.number)} ${esc(ch.own ? ch.call : displayCall(ch))}</span><span style="color:var(--muted)">${p ? `${clock(p._s)} to ${clock(p._e)}` : ''}</span>`;525+ f(g, 'title').textContent = p ? p.title : ch.own ? ch.name : 'No listings';526+ f(g, 'ep').textContent = episodeLine(p);527+ // An own channel's category is among its programs' genres; with nothing528+ // listed it shows on its own, with the channel's description.529+ const genres = p ? p.genres || [] : ch.own && ch.category !== 'Other' ? [ch.category] : [];530+ f(g, 'meta').innerHTML = chipsFor(p, ch) + genres.map((x) => `<span class="chip">${esc(x)}</span>`).join('');531+ f(g, 'cast').textContent = castLine(p);532+ f(g, 'desc').textContent = p ? p.description || '' : ch.description || '';533+ const live = !p || p._s <= now;534+ const ok = keyHint('Enter', 'Enter');535+ const when = !live ? `Starts in ${humanMinutes(Math.round((p._s - now) / MIN))} | ${ok} for more` : p && p._e <= now ? `Ended ${clock(p._e)} | ${ok} to watch ${esc(ch.number)}` : `${ok} to watch`;536+ const more = !live ? '' : prompts.pad ? hints([['i', 'I', 'for more']]) : `hold ${ok} for more`;537+ const rec = state.info.dvr && p && p._e > now && !ch.weather && !ch.custom && onKeys() ? ' | R record, Shift R series, N new only' : '';538+ f(g, 'hint').innerHTML = `${when}${more ? `, ${more}` : ''}${rec}`;539+ renderGuideActions(ch, p);540+ const art = f(g, 'art');541+ if (p && p.image) { art.style.backgroundImage = `url('${imageURL(p.image)}')`; art.classList.add('has'); } else { art.style.backgroundImage = ''; art.classList.remove('has'); }542+}
A frontend/dist/js/input.js +579 -0
@@ -0,0 +1,579 @@
1+'use strict';2+3+// Keys, a remote, controllers, the dock, focus and the hints that name them.4+5+// ---------- keyboard ----------6+// Text fields keep their keys (but Esc).7+const TEXT_INPUT = 'input:not([type=checkbox]):not([type=radio]), textarea, select';8+9+// The media keys of a TV remote (over HDMI-CEC, or an Android TV remote)10+// or a keyboard, as the keys the views handle: [key, shift]. Page Down is11+// channel up.12+const KEY_ALIASES = {13+ MediaPlayPause: [' '], MediaPlay: [' '], MediaPause: [' '],14+ MediaFastForward: ['ArrowRight'], MediaRewind: ['ArrowLeft'],15+ MediaTrackNext: ['PageDown'], MediaTrackPrevious: ['PageUp'],16+ ChannelUp: ['PageDown'], ChannelDown: ['PageUp'],17+ MediaRecord: ['r'], BrowserBack: ['Escape'], GoBack: ['Escape'],18+ Guide: ['g'], Info: ['i'], ClosedCaptionToggle: ['c'], MediaLast: ['l'], MediaAudioTrack: ['v'],19+};20+21+// pressKey does what a key does, for a controller button or a media key:22+// an untrusted keydown runs the same handler and nothing else (no button23+// is pressed, no text typed).24+function pressKey(key, { shiftKey = false, repeat = false } = {}) {25+ (document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey, repeat, bubbles: true, cancelable: true }));26+}27+28+// Keys held down: the Android TV app's WebView sends a held key again29+// without marking it a repeat, so another keydown before its keyup is one.30+const held = new Map(); // key: when its last keydown came31+const HELD_MS = 700;32+33+function wireKeys() {34+ document.addEventListener('keydown', (e) => {35+ let again = e.repeat;36+ if (e.isTrusted) {37+ setPrompts(false);38+ const at = held.get(e.key);39+ again = again || (at !== undefined && e.timeStamp - at < HELD_MS);40+ held.set(e.key, e.timeStamp);41+ }42+ const alias = KEY_ALIASES[e.key];43+ if (alias) {44+ e.preventDefault();45+ return pressKey(alias[0], { shiftKey: !!alias[1], repeat: again });46+ }47+ const typing = e.target.matches && e.target.matches(TEXT_INPUT);48+ if (document.body.classList.contains('booting')) return bootKey(e, typing);49+ if (typing && e.key !== 'Escape') return;50+ if (e.metaKey || e.ctrlKey || e.altKey) return;51+ const k = e.key;52+ // A held OK, or Space, acts once; held, OK is a long press.53+ if ((k === 'Enter' || k === ' ') && again) {54+ e.preventDefault();55+ if (k === 'Enter') holdLong();56+ return;57+ }58+ if (osd.open) osdTouch();59+ if (state.dock >= 0 && dockKey(e)) return;60+ if (typing) {61+ // Esc puts a field away, back to its row.62+ e.preventDefault();63+ e.target.blur();64+ return restoreFocus();65+ }66+ if (state.view === 'settings' && settingsKey(e)) return;67+ if (state.view === 'tv' && osd.open && osdKey(e)) return;68+ if (k === 'Escape') {69+ if (state.entry) return cancelEntry();70+ if (viewBack()) return;71+ if (state.view !== 'tv') return setView('tv');72+ if (state.recording && state.current) return tune(state.current);73+ return hideBanner();74+ }75+ if (k === 'g' || k === 'G') return setView(state.view === 'guide' ? 'tv' : 'guide');76+ if ((k === 'a' || k === 'A') && state.info.antenna !== false) {77+ if (receptionShown()) return setView('tv');78+ if (state.view !== 'settings') setView('settings');79+ return openSettings('reception', 'list');80+ }81+ if ((k === 'd' || k === 'D') && state.info.dvr) return setView(state.view === 'recordings' ? 'tv' : 'recordings');82+ if ((k === 'w' || k === 'W') && state.view !== 'recordings') return setView(state.view === 'weather' ? 'tv' : 'weather');83+ if (k === ',' || k === 's' || k === 'S') return setView(state.view === 'settings' ? 'tv' : 'settings');84+ if (k === 'm' || k === 'M') {85+ const onWX = state.current && state.current.weather;86+ const muted = !(onWX ? state.userMuted : video.muted);87+ state.userMuted = muted;88+ if (!state.wxPreview) video.muted = muted;89+ wxMusic.muted = muted;90+ return toast(muted ? 'Muted' : 'Sound on', 1200);91+ }92+ if ((k === 'u' || k === 'U') && state.lastHidden) {93+ const key = state.lastHidden;94+ state.lastHidden = null;95+ return unhide([key]).then(() => toast('Channel shown again', 1800));96+ }97+ if (k === 'f') return toggleFullscreen();98+99+ if (state.view === 'guide') return guideKey(e);100+ if (state.view === 'recordings') return recordingsKey(e);101+ if (state.view === 'weather') return weatherKey(e);102+ if (state.view !== 'tv') return;103+104+ if (/^[0-9]$/.test(k) || (k === '.' && state.entry)) { e.preventDefault(); return entryKey(k); }105+ switch (k) {106+ case 'ArrowUp': case 'PageUp': e.preventDefault(); return step(-1);107+ case 'ArrowDown': case 'PageDown': e.preventDefault(); return step(1);108+ // Left and Right skip, and bring up the controls with the timeline109+ // picked, for more of the same.110+ case 'ArrowLeft': e.preventDefault(); skip(e.shiftKey ? -60 : -10); return openOSD('timeline');111+ case 'ArrowRight': e.preventDefault(); skip(e.shiftKey ? 60 : 30); return openOSD('timeline');112+ case ' ': e.preventDefault(); return togglePause();113+ case 'End': e.preventDefault(); return goLive();114+ case 'Enter': e.preventDefault(); return state.entry ? commitEntry() : openOSD();115+ case 'i': case 'I': return cycleBanner();116+ case 'l': case 'L': case 'Backspace': return state.previous && tune(state.previous);117+ case 'r': return recordNow('once');118+ case 'R': return recordNow('series', false);119+ case 'n': case 'N': return recordNow('series', true);120+ case 'c': case 'C': return toggleCaptions();121+ case 'v': case 'V': return cycleAudio();122+ case 'F': return toggleFavorite(state.current);123+ default:124+ }125+ });126+ document.addEventListener('keyup', (e) => {127+ held.delete(e.key);128+ holdEnd(e);129+ });130+ window.addEventListener('blur', () => held.clear());131+}132+133+// ---------- holding OK ----------134+// Holding OK (or Enter) for more, as on Android TV and in Kodi, where a long135+// press brings up a context menu: in the guide, a program's actions. A136+// press acts on its keyup; a hold once the key comes again (Android sends137+// a held key again at its long-press timeout, a keyboard repeats) or it's138+// been down HOLD_MS. A controller's button (pressKey) has no keyup, and139+// acts at once.140+const HOLD_MS = 500;141+const hold = { timer: 0, short: null, long: null };142+143+function holdStart(short, long) {144+ clearTimeout(hold.timer);145+ hold.short = short;146+ hold.long = long;147+ hold.timer = setTimeout(holdLong, HOLD_MS);148+}149+150+function holdLong() {151+ if (!hold.timer) return;152+ clearTimeout(hold.timer);153+ hold.timer = 0;154+ const long = hold.long;155+ hold.short = null;156+ hold.long = null;157+ if (long) long();158+}159+160+function holdEnd(e) {161+ if (e.key !== 'Enter' || !hold.timer) return;162+ clearTimeout(hold.timer);163+ hold.timer = 0;164+ const short = hold.short;165+ hold.short = null;166+ hold.long = null;167+ if (short) short();168+}169+170+// bootKey is a key while starting: on the connect prompt the arrows move171+// between its fields and Connect (Up and Down only, while typing), and OK172+// on a field puts the cursor in it.173+function bootKey(e, typing) {174+ const form = $('.boot-connect');175+ if (form.hidden) {176+ if (!typing && (e.key === 'r' || e.key === 'R')) location.reload();177+ return;178+ }179+ const items = $$('.fx', form);180+ const el = document.activeElement;181+ const i = items.indexOf(el && el.closest ? el.closest('.fx') : null);182+ if (e.key === 'Enter' && el && el.matches('.boot-field')) {183+ e.preventDefault();184+ const input = $('input', el);185+ input.focus();186+ input.select();187+ return;188+ }189+ const dir = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key];190+ if (!dir || (typing && (e.key === 'ArrowLeft' || e.key === 'ArrowRight'))) return;191+ e.preventDefault();192+ bootField(items[i < 0 ? 0 : Math.max(0, Math.min(items.length - 1, i + dir))]);193+}194+195+// bootField moves to a field of the connect prompt: on a TV to its row, as196+// a field with the cursor in it would bring up the keyboard at the next197+// key; elsewhere into it, to type.198+function bootField(el) {199+ if (!el) return;200+ const input = el.matches('.boot-field') && !androidTV ? $('input', el) : null;201+ if (input) { input.focus(); input.select(); } else focusEl(el);202+}203+204+// On the Android TV app the keyboard comes up over the page (the visual205+// viewport shrinks); put away with Back, it leaves its field with the206+// cursor, where the next arrow would bring it up again. So the field lets207+// go then, back to its row, as Esc does: a setting's text goes back to208+// what it was (Enter, the keyboard's own key, keeps it).209+const ime = { open: false };210+if (androidTV && window.visualViewport) {211+ visualViewport.addEventListener('resize', () => {212+ const el = document.activeElement;213+ const typing = !!(el && el.matches && el.matches(TEXT_INPUT));214+ if (visualViewport.height < innerHeight * 0.9) { ime.open = typing; return; }215+ if (!ime.open) return;216+ ime.open = false;217+ if (!typing) return;218+ if (el.dataset.was !== undefined) el.value = el.dataset.was;219+ const row = el.closest('.fx');220+ el.blur();221+ if (row) focusEl(row); else restoreFocus();222+ });223+}224+225+// airwavesBack is the Android TV app's Back key: it does what Esc does and226+// tells whether that went back from anything. False (live TV with nothing227+// open, or still starting) lets the app close.228+window.airwavesBack = () => {229+ if (!state.settings || document.body.classList.contains('booting')) return false;230+ const el = document.activeElement;231+ const typing = !!(el && el.matches && el.matches(TEXT_INPUT));232+ const open = state.dock >= 0 || typing || !!state.entry || state.view !== 'tv' || osd.open233+ || !!(state.recording && state.current) || $('#banner').classList.contains('show');234+ if (!open) return false;235+ pressKey('Escape');236+ return true;237+};238+239+// In the background (the Home key) the Android TV app's live TV stops, so240+// the server's tuner is free, and tunes again on return; a recording241+// pauses where it is.242+document.addEventListener('visibilitychange', () => {243+ if (!androidTV || !state.settings || document.body.classList.contains('booting')) return;244+ if (document.hidden && state.recording) {245+ video.pause();246+ } else if (document.hidden && state.current) {247+ state.away = true;248+ state.tuneToken++;249+ resetPlayer();250+ wxMusic.pause();251+ api().StopTV();252+ } else if (!document.hidden && state.away) {253+ state.away = false;254+ if (!state.recording) tune(state.current, { quiet: true });255+ }256+});257+258+// ---------- dock ----------259+// Without letter keys (a TV remote, a controller) the dock leads to every260+// view: Left from its left edge, or Up from the top of a view, moves to261+// it, and on TV it's the row above the on-screen controls. Left and Right262+// pick, Enter opens, Esc or Down goes back to where the focus was.263+const dockButtons = () => $$('#dock button').filter((b) => b.offsetParent);264+265+function openDock() {266+ const el = document.activeElement;267+ if (el && el !== document.body && !el.matches(TEXT_INPUT)) el.blur();268+ state.dock = Math.max(0, dockButtons().findIndex((b) => b.dataset.view === state.view));269+ renderDock();270+}271+272+function closeDock(restore = true) {273+ state.dock = -1;274+ renderDock();275+ if (restore) restoreFocus();276+}277+278+function renderDock() {279+ document.body.classList.toggle('dock-nav', state.dock >= 0);280+ dockButtons().forEach((b, i) => b.classList.toggle('pick', i === state.dock));281+}282+283+// dockKey handles a key while the dock is picked; other keys leave it.284+function dockKey(e) {285+ const buttons = dockButtons();286+ switch (e.key) {287+ case 'ArrowLeft': state.dock = Math.max(0, state.dock - 1); break;288+ case 'ArrowRight': state.dock = Math.min(buttons.length - 1, state.dock + 1); break;289+ case 'ArrowUp': break;290+ case 'Enter': case ' ': {291+ const b = buttons[state.dock];292+ closeDock(false);293+ if (b) setView(b.dataset.view);294+ e.preventDefault();295+ return true;296+ }297+ case 'ArrowDown':298+ // In the guide the filters sit just below the dock.299+ if (state.view === 'guide') { closeDock(false); guideZone('filters'); } else closeDock();300+ e.preventDefault();301+ return true;302+ case 'Escape': closeDock(); e.preventDefault(); return true;303+ default: closeDock(); return false;304+ }305+ e.preventDefault();306+ renderDock();307+ return true;308+}309+310+// ---------- focus ----------311+// Where the remote, a controller or the arrow keys are is always marked,312+// the same way in every view (style.css, Focus). Buttons, fields and the313+// weather's parts take the page's focus (.fx); the guide's grid and the314+// lists of Recordings, Reception and Settings mark their pick with a class,315+// while that part of the view has the focus (its data-zone). Only keys316+// focus: a click leaves the focus where it was (wireMouse).317+const isShown = (el) => !!el && !el.hidden && el.getClientRects().length > 0;318+319+function focusEl(el, scroll = true) {320+ if (!el) return false;321+ if (document.activeElement !== el) el.focus({ preventScroll: true });322+ if (scroll && el.closest('.wx-main, .r-grid, .r-list, .s-list, .a-tablewrap')) el.scrollIntoView({ block: 'nearest' });323+ return document.activeElement === el;324+}325+326+// blurIn takes the focus off whatever has it inside root.327+function blurIn(root) {328+ const el = document.activeElement;329+ if (el && el !== document.body && root.contains(el)) el.blur();330+}331+332+// neighbor is the item dir (-1 or 1) from el in items, or null past either333+// end: rows of buttons stop at their ends.334+function neighbor(items, el, dir) {335+ const i = items.indexOf(el);336+ return i < 0 ? items[0] || null : items[i + dir] || null;337+}338+339+// restoreFocus puts the focus back where it was in the view, after the dock340+// or a field let it go.341+function restoreFocus() {342+ if (state.dock >= 0) return;343+ if (state.view === 'tv' && osd.open) osdFocus(osd.last);344+ else if (state.view === 'guide') guideZone(guideUI.zone);345+ else if (state.view === 'recordings') recZone(recUI.zone);346+ else if (state.view === 'settings') settingsRefocus();347+ else if (state.view === 'weather') wxFocus();348+}349+350+// viewBack is Back in an inner part of a view (a program's or a351+// recording's actions, the guide's filters): it goes to the view's main352+// part, and reports whether it did.353+function viewBack() {354+ if (state.view === 'guide' && state.recPick) { closeRecordPick(); return true; }355+ if (state.view === 'guide' && guideUI.zone !== 'grid') { guideZone('grid'); return true; }356+ if (state.view === 'recordings' && recUI.zone === 'actions') { recZone('list'); return true; }357+ // In a tab of Settings, Back goes to the tabs, as on Android TV.358+ if (state.view === 'settings' && settingsUI.zone === 'list') { settingsZone('tabs'); return true; }359+ return false;360+}361+362+// ---------- game controllers ----------363+// A game controller does what the keyboard does: each button stands for a364+// key and goes through the same handler. Indexes are the standard gamepad365+// mapping (A, B, X, Y, LB, RB, LT, RT, Back, Start, L3, R3, d-pad); Guide is366+// Steam's. WebKit lists a controller once a button is pressed; while one is367+// connected the pads are read every frame, and not while the window is368+// hidden. Pads are merged, so one controller seen twice presses once.369+//370+// The pads are read raw, so Steam's own menus don't hide them from the app.371+// Guide opens Steam's menu, and Guide with A its quick access menu, which372+// leave the window focused under gamescope: from Guide until Guide again or373+// B (which closes them), the pads are Steam's. Nothing acts while the window374+// is unfocused either, and a button held when the app gets the pads back375+// acts only once let go and pressed again.376+const PAD_KEYS = [377+ ['Enter'], ['Escape'], ['i'], ['g'],378+ ['PageUp'], ['PageDown'], ['ArrowLeft', true], ['ArrowRight', true],379+ ['w'], [','], ['m'], [' '],380+ ['ArrowUp'], ['ArrowDown'], ['ArrowLeft'], ['ArrowRight'],381+];382+const PAD_ARROWS = 12; // the d-pad (and the left stick) repeat when held383+// A direction held longer than this stops repeating until it's let go: a384+// pad whose state froze mid-press (asleep, or taken by Steam) would385+// otherwise move the guide or the dock on its own.386+const PAD_REPEAT_MAX = 4000;387+const pad = { raf: 0, held: [], stick: -1, greeted: false, guide: false, away: false, mute: false };388+const PAD_GUIDE = 16;389+const PAD_B = 1;390+391+function wirePads() {392+ if (!navigator.getGamepads) return;393+ window.addEventListener('gamepadconnected', (e) => {394+ log('info', `controller connected: ${e.gamepad.id} (${e.gamepad.mapping || 'no'} mapping)`);395+ setPadFamily(e.gamepad.id);396+ if (!pad.greeted) {397+ pad.greeted = true;398+ toast('Controller connected', 2500);399+ }400+ padWatch();401+ });402+ window.addEventListener('gamepaddisconnected', (e) => log('info', `controller disconnected: ${e.gamepad.id}`));403+ document.addEventListener('visibilitychange', () => {404+ if (!document.hidden) {405+ pad.mute = true;406+ return padWatch();407+ }408+ cancelAnimationFrame(pad.raf);409+ pad.raf = 0;410+ });411+ window.addEventListener('focus', () => {412+ pad.away = false;413+ pad.mute = true;414+ });415+ padWatch(); // pads already listed, after a reload416+}417+418+// padWatch reads the pads on the next frame, unless the window is hidden.419+function padWatch() {420+ if (!pad.raf && !document.hidden) pad.raf = requestAnimationFrame(padPoll);421+}422+423+function padPoll(now) {424+ pad.raf = 0;425+ const pads = [...navigator.getGamepads()].filter(Boolean);426+ if (!pads.length) {427+ pad.held = [];428+ pad.stick = -1;429+ return;430+ }431+ const down = padButtons(pads);432+ const guide = pads.some((p) => p.buttons[PAD_GUIDE] && p.buttons[PAD_GUIDE].pressed);433+ if (guide && !pad.guide) pad.away = !pad.away;434+ else if (pad.away && pad.held[PAD_B] && !down[PAD_B]) pad.away = false;435+ pad.guide = guide;436+ const live = !pad.away && !pad.mute && document.hasFocus();437+ pad.mute = false;438+ down.forEach((on, i) => {439+ const h = pad.held[i];440+ if (!on) {441+ pad.held[i] = null;442+ } else if (!live) {443+ pad.held[i] = h || { next: Infinity }; // acts once let go and pressed again444+ } else if (!h) {445+ pad.held[i] = { next: now + 400, since: now };446+ padPress(i, false);447+ } else if (i >= PAD_ARROWS && now >= h.next && now - h.since < PAD_REPEAT_MAX) {448+ h.next = now + 120;449+ padPress(i, true);450+ }451+ });452+ padWatch();453+}454+455+// padButtons merges the pads' buttons, with the left stick as the d-pad: it456+// points along its stronger axis once pushed past 0.6, until back under 0.35.457+function padButtons(pads) {458+ const down = PAD_KEYS.map(() => false);459+ let x = 0;460+ let y = 0;461+ for (const p of pads) {462+ for (let i = 0; i < down.length && i < p.buttons.length; i++) {463+ if (p.buttons[i].pressed || p.buttons[i].value > 0.5) down[i] = true;464+ }465+ if (Math.abs(p.axes[0] || 0) > Math.abs(x)) x = p.axes[0];466+ if (Math.abs(p.axes[1] || 0) > Math.abs(y)) y = p.axes[1];467+ }468+ const along = { 12: -y, 13: y, 14: -x, 15: x };469+ if (pad.stick >= 0 && along[pad.stick] < 0.35) pad.stick = -1;470+ if (pad.stick < 0 && Math.max(Math.abs(x), Math.abs(y)) > 0.6) {471+ pad.stick = Math.abs(x) > Math.abs(y) ? (x < 0 ? 14 : 15) : (y < 0 ? 12 : 13);472+ }473+ if (pad.stick >= 0) down[pad.stick] = true;474+ return down;475+}476+477+function padPress(i, repeat) {478+ setPrompts(true);479+ // On the TV, A plays and pauses: the guide has its own button, and the480+ // remote's OK (Enter) brings up the controls. With them up, A presses.481+ if (i === 0 && state.view === 'tv' && !state.entry && !osd.open && state.dock < 0) return pressKey(' ', { repeat });482+ const [key, shiftKey] = PAD_KEYS[i];483+ pressKey(key, { shiftKey: !!shiftKey, repeat });484+}485+486+// ---------- key and button hints ----------487+// Hints name keys, or a controller's buttons once one was used (Kenney's488+// CC0 Input Prompts, in vendor/kenney-prompts), until a key is pressed489+// again. Glyphs by the key a button stands for (PAD_KEYS); the face buttons490+// are where the standard mapping puts them, so a Switch pad's read B A Y X.491+const GLYPHS = {492+ xbox: ['xbox_button_a', 'xbox_button_b', 'xbox_button_x', 'xbox_button_y', 'xbox_lb', 'xbox_rb', 'xbox_lt', 'xbox_rt',493+ 'xbox_button_view', 'xbox_button_menu', 'xbox_ls', 'xbox_rs', 'xbox_dpad_none', 'xbox_dpad_vertical', 'xbox_dpad_horizontal'],494+ playstation: ['playstation_button_cross', 'playstation_button_circle', 'playstation_button_square', 'playstation_button_triangle',495+ 'playstation_trigger_l1', 'playstation_trigger_r1', 'playstation_trigger_l2', 'playstation_trigger_r2', 'playstation5_button_create',496+ 'playstation5_button_options', 'playstation_button_l3', 'playstation_button_r3', 'playstation_dpad_none', 'playstation_dpad_vertical', 'playstation_dpad_horizontal'],497+ switch: ['switch_button_b', 'switch_button_a', 'switch_button_y', 'switch_button_x', 'switch_button_l', 'switch_button_r', 'switch_button_zl',498+ 'switch_button_zr', 'switch_button_minus', 'switch_button_plus', 'switch_stick_l_press', 'switch_stick_r_press', 'switch_dpad_none', 'switch_dpad_vertical', 'switch_dpad_horizontal'],499+ steamdeck: ['steamdeck_button_a', 'steamdeck_button_b', 'steamdeck_button_x', 'steamdeck_button_y', 'steamdeck_button_l1', 'steamdeck_button_r1',500+ 'steamdeck_button_l2', 'steamdeck_button_r2', 'steamdeck_button_view', 'steamdeck_button_options', 'steamdeck_stick_l_press', 'steamdeck_stick_r_press',501+ 'steamdeck_dpad_none', 'steamdeck_dpad_vertical', 'steamdeck_dpad_horizontal'],502+};503+// The key each glyph above stands for, in order.504+const GLYPH_KEYS = ['Enter', 'Escape', 'i', 'g', 'PageUp', 'PageDown', 'ShiftLeft', 'ShiftRight', 'w', ',', 'm', ' ', 'Arrows', 'UpDown', 'LeftRight'];505+const prompts = { pad: false, family: 'xbox', detected: 'xbox' };506+507+// An 8BitDo pad's X and Y arrive swapped (its X, on the left, reads as the508+// standard mapping's top button), so it gets Xbox glyphs with X and Y509+// traded.510+GLYPHS.xboxswap = GLYPHS.xbox.map((g, i) => GLYPHS.xbox[i === 2 ? 3 : i === 3 ? 2 : i]);511+512+// PAD_LABELS name the glyph sets, for the Settings choice that overrides513+// what the pad's id suggests.514+const PAD_LABELS = [['', 'Auto'], ['xbox', 'Xbox'], ['xboxswap', 'Xbox, X and Y swapped'], ['switch', 'Nintendo'], ['playstation', 'PlayStation'], ['steamdeck', 'Steam Deck']];515+const padLabelsKey = 'airwaves.padLabels';516+517+function setPadFamily(id) {518+ prompts.detected = /054c|sony|playstation|dualsense|dualshock/i.test(id) ? 'playstation'519+ : /057e|nintendo|switch/i.test(id) ? 'switch'520+ : /2dc8|8bitdo/i.test(id) ? 'xboxswap'521+ : /28de|valve|steam deck/i.test(id) ? 'steamdeck' : 'xbox';522+ applyPadFamily();523+}524+525+function applyPadFamily() {526+ const forced = localStorage.getItem(padLabelsKey) || '';527+ const family = GLYPHS[forced] ? forced : prompts.detected;528+ if (family === prompts.family) return;529+ prompts.family = family;530+ renderHints();531+}532+533+function setPrompts(pad) {534+ if (pad === prompts.pad) return;535+ prompts.pad = pad;536+ document.body.classList.toggle('pad-input', pad);537+ renderHints();538+}539+540+// glyph is the controller button for key, or '' when there is none.541+function glyph(key, label) {542+ const i = GLYPH_KEYS.indexOf(key);543+ if (i < 0) return '';544+ const file = GLYPHS[prompts.family][i];545+ return `<img class="glyph" src="vendor/kenney-prompts/${file}${/press|dpad_none/.test(file) ? '' : '_outline'}.svg" alt="${esc(label)}">`;546+}547+548+// The keys of the Android TV app's remote, as hints name them.549+const REMOTE_KEYS = { Enter: 'OK', Escape: 'Back', ' ': 'Play/Pause', Arrows: 'Arrows', UpDown: 'Up, Down', LeftRight: 'Left, Right' };550+551+// keyHint shows how to press key: its button after a controller was used,552+// else its label (on the Android TV app, the remote's); null when the553+// controller or the remote has no such button.554+const keyHint = (key, label) => {555+ if (prompts.pad) return glyph(key, label) || null;556+ if (platform === 'tv') return REMOTE_KEYS[key] ? esc(REMOTE_KEYS[key]) : null;557+ return esc(label);558+};559+560+// hints joins [key, label, what] into a line of hints, leaving out what the561+// controller can't do.562+const hints = (items) => items.map(([key, label, what]) => {563+ const k = keyHint(key, label);564+ return k == null ? '' : `${k} ${what}`;565+}).filter(Boolean).join(' | ');566+567+// renderHints redraws whatever shows keys, after the input changed.568+function renderHints() {569+ for (const k of $$('#dock kbd')) {570+ const h = keyHint(k.dataset.key, k.dataset.label);571+ k.innerHTML = h || '';572+ k.hidden = h == null;573+ }574+ renderSettings();575+ if (!state.report) return;576+ if (state.view === 'guide') { guideShown = []; renderGuide(); }577+ if (state.view === 'recordings') renderRecordings();578+ renderBanner();579+}
A frontend/dist/js/playback.js +361 -0
@@ -0,0 +1,361 @@
1+'use strict';2+3+// Live rewind, the heartbeat, a recording's progress, playback's state and the system's media controls.4+5+// ---------- live rewind, captions and audio ----------6+function liveEdge() {7+ const r = video.seekable;8+ return r && r.length ? r.end(r.length - 1) : video.duration || 0;9+}10+11+// livePoint is where live TV plays from, and what counts as live: hls.js's12+// place a little behind the edge, where it has the video (at the edge13+// itself it has none yet, and WebKit drops back to the start), else the14+// edge.15+function livePoint() {16+ const h = state.hls;17+ if (h) {18+ const p = h.liveSyncPosition;19+ if (Number.isFinite(p) && p > 0) return Math.min(p, liveEdge());20+ if (Number.isFinite(h.targetLatency)) return Math.max(0, liveEdge() - h.targetLatency);21+ }22+ return liveEdge();23+}24+25+function skip(seconds) {26+ if (!video.src && !state.hls) return;27+ const r = video.seekable;28+ if (!r || !r.length) return;29+ const from = video.currentTime;30+ const t = Math.min(Math.max(from + seconds, r.start(0)), livePoint());31+ video.currentTime = t;32+ noteSkip(t - from, seconds);33+ showTimeshift();34+}35+36+function togglePause() {37+ if (video.paused && state.staleStream) return restartStream();38+ if (video.paused) video.play().catch(() => {}); else video.pause();39+ showTimeshift();40+}41+42+// ---------- heartbeat ----------43+// While a stream is open, playing or paused, the app says so every 30 s:44+// the server stops streams nobody fetches from for 2 minutes, freeing45+// their tuners. One it stopped anyway (the app was asleep, or in the46+// background) starts again at once if it was playing on TV, else on the47+// next Play or return to TV.48+async function heartbeat() {49+ if (!(video.src || state.hls) || state.staleStream) return;50+ let alive = true;51+ try {52+ alive = await api().Heartbeat();53+ } catch (e) {54+ return; // the server is away; the player has its own errors55+ }56+ if (alive) return;57+ log('info', 'the server had stopped the stream');58+ if (state.view === 'tv' && !video.paused) restartStream();59+ else state.staleStream = true;60+}61+62+// restartStream starts the stream on again: the channel, or the recording63+// from where it is.64+function restartStream() {65+ state.staleStream = false;66+ if (state.recording) {67+ const it = state.recording;68+ return playRecording(it, (state.recOffset || 0) + (video.currentTime || 0));69+ }70+ return state.current ? tune(state.current, { quiet: true }) : undefined;71+}72+73+function goLive() {74+ if (state.recording) return;75+ video.currentTime = livePoint();76+ video.play().catch(() => {});77+ flashPlace(true);78+ showTimeshift();79+}80+81+// behindLive is how far playback trails live: hls.js's live point, or for82+// the webview's own HLS the edge less the delay measured when playback83+// started.84+function behindLive() {85+ if (state.recording || !video.seekable || !video.seekable.length) return 0;86+ if (state.hls) return Math.max(0, livePoint() - video.currentTime);87+ return Math.max(0, liveEdge() - video.currentTime - (state.liveBaseline || 0));88+}89+90+const mmss = (sec) => {91+ sec = Math.max(0, Math.round(sec));92+ const h = Math.floor(sec / 3600);93+ const m = Math.floor((sec % 3600) / 60);94+ const s = String(sec % 60).padStart(2, '0');95+ return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;96+};97+98+// showTimeshift says in the banner's time line how far behind live the99+// picture is, or where in a recording.100+function showTimeshift() {101+ let text = '';102+ if (!video.hidden && (video.src || state.hls)) {103+ if (state.recording) {104+ text = `${video.paused ? 'Paused, ' : ''}${mmss((state.recOffset || 0) + video.currentTime)} of ${mmss(state.recording.duration || 0)}`;105+ } else {106+ const behind = behindLive();107+ if (video.paused) text = behind > 2 ? `Paused, ${mmss(behind)} behind live` : 'Paused';108+ else if (behind > 4) text = `${mmss(behind)} behind live`;109+ }110+ }111+ const el = f($('#banner'), 'shift');112+ if (el.textContent !== text) el.textContent = text;113+}114+115+// ---------- recording progress ----------116+let progressTimer = 0;117+function saveRecordingProgress() {118+ const it = state.recording;119+ if (!it || !it.id) return;120+ const pos = (state.recOffset || 0) + video.currentTime;121+ if (pos < 1) return;122+ it.position = pos;123+ api().SaveProgress(it.id, pos, it.duration || 0).catch((e) => log('warn', `progress: ${e}`));124+}125+126+video.addEventListener('timeupdate', () => {127+ if ($('#banner').classList.contains('show')) showTimeshift();128+ if (osd.open) renderTimeline();129+ if (state.recording && Date.now() - progressTimer > 15000) {130+ progressTimer = Date.now();131+ saveRecordingProgress();132+ }133+});134+video.addEventListener('pause', () => { if (state.recording) saveRecordingProgress(); });135+video.addEventListener('play', () => updatePlaybackState());136+video.addEventListener('pause', () => updatePlaybackState());137+// The controls stay while paused, and count down again once playing.138+for (const ev of ['play', 'pause', 'playing', 'emptied']) {139+ video.addEventListener(ev, () => {140+ if (!osd.open) return;141+ renderOSD();142+ osdTouch();143+ });144+}145+video.addEventListener('playing', () => {146+ if (!state.recording && state.liveBaseline == null) state.liveBaseline = Math.max(0, liveEdge() - video.currentTime);147+ applyCaptions();148+ applyAudio();149+});150+if (video.textTracks) {151+ video.textTracks.addEventListener('addtrack', applyCaptions);152+ video.textTracks.addEventListener('removetrack', applyCaptions);153+ video.textTracks.addEventListener('change', keepCaptions);154+}155+if (video.audioTracks) video.audioTracks.addEventListener('addtrack', applyAudio);156+157+// ---------- playback state ----------158+// A small badge at the picture's bottom right says what playback is doing,159+// on the TV view. It stays only while there's something to do about it:160+// paused (with how far behind live, or where in a recording), and161+// buffering (once a stream has played, after 300 ms of waiting). The rest162+// flashes and fades: a skip (the amount, adding up over quick presses) and163+// then where it left the picture; how far behind live on resuming, or on164+// the info key; "Live" on going back to live. Playing behind live, it's165+// gone: the banner says how far. It keeps above the banner and the weather166+// crawl, as the captions do.167+const PS_ICONS = {168+ pause: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="3" y="2" width="3.6" height="12" rx="1"/><rect x="9.4" y="2" width="3.6" height="12" rx="1"/></svg>',169+ back: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8.2 3 1.5 8l6.7 5zM15 3 8.3 8 15 13z"/></svg>',170+ ahead: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M7.8 3l6.7 5-6.7 5zM1 3l6.7 5L1 13z"/></svg>',171+ wait: '<i class="ps-spin"></i>',172+ live: '<i class="ps-dot"></i>',173+};174+const PS_SKIP_MS = 1500; // a skip shows this long after the last press175+const PS_PLACE_MS = 2500; // then where it left the picture, this long176+const PS_WAIT_MS = 300; // shorter waits for data go unmarked177+const ps = {178+ el: $('#pstate'), skip: 0, dir: 0, skipUntil: 0, skipTimer: 0, placeUntil: 0, placeLive: false, placeTimer: 0,179+ played: false, paused: false, waiting: false, waitTimer: 0, tick: 0, shown: '',180+};181+182+// noteSkip shows a skip of delta seconds (what playback moved, which is183+// less than asked at either end of what can be rewound), then where it184+// left the picture.185+function noteSkip(delta, asked) {186+ const now = Date.now();187+ ps.skip = now < ps.skipUntil && Math.sign(asked) === ps.dir ? ps.skip + delta : delta;188+ ps.dir = Math.sign(asked);189+ ps.skipUntil = now + PS_SKIP_MS;190+ clearTimeout(ps.skipTimer);191+ ps.skipTimer = setTimeout(renderPlayState, PS_SKIP_MS + 20);192+ flashPlace(ps.dir > 0, PS_SKIP_MS);193+}194+195+// flashPlace shows for a moment how far behind live the picture is, or196+// where in a recording; at live, "Live" when live is set.197+function flashPlace(live = false, after = 0) {198+ ps.placeUntil = Date.now() + after + PS_PLACE_MS;199+ ps.placeLive = live;200+ clearTimeout(ps.placeTimer);201+ ps.placeTimer = setTimeout(renderPlayState, after + PS_PLACE_MS + 20);202+ renderPlayState();203+}204+205+function skipLabel() {206+ const n = Math.round(ps.skip);207+ if (!n) return ps.dir > 0 && !state.recording ? 'Live' : ps.dir > 0 ? 'End' : 'Start';208+ const a = Math.abs(n);209+ return `${n < 0 ? '-' : '+'}${a < 100 ? `${a} s` : mmss(a)}`;210+}211+212+// placeText is where the picture is: how far behind live (when it's more213+// than the moment a pause or the stream's own delay accounts for), or the214+// position in a recording.215+function placeText(paused) {216+ if (state.recording) return `${mmss((state.recOffset || 0) + video.currentTime)} / ${mmss(state.recording.duration || 0)}`;217+ const behind = behindLive();218+ return behind > (paused ? 2 : 4) ? `-${mmss(behind)}` : '';219+}220+221+function renderPlayState() {222+ let kind = '';223+ let text = '';224+ const playing = !!(video.src || state.hls) && !video.hidden && $('#nosignal').hidden && !stage.classList.contains('tuning');225+ if (playing) {226+ const now = Date.now();227+ if (now < ps.skipUntil) {228+ kind = ps.dir < 0 ? 'back' : 'ahead';229+ text = skipLabel();230+ } else if (video.paused) {231+ kind = 'pause';232+ text = placeText(true);233+ } else if (ps.waiting) {234+ kind = 'wait';235+ } else if (now < ps.placeUntil) {236+ text = placeText(false);237+ if (text) kind = 'place';238+ else if (ps.placeLive && !state.recording) { kind = 'live'; text = 'Live'; }239+ }240+ }241+ // While paused the live edge moves on with no timeupdate to say so.242+ if (kind === 'pause' && !state.recording && !ps.tick) ps.tick = setInterval(renderPlayState, 1000);243+ if (kind !== 'pause' && ps.tick) { clearInterval(ps.tick); ps.tick = 0; }244+ const key = `${kind}|${text}`;245+ if (key === ps.shown) return;246+ ps.shown = key;247+ if (!kind) {248+ ps.el.classList.remove('show'); // fades out as it was249+ return;250+ }251+ ps.el.className = `show ps-${kind}`;252+ ps.el.innerHTML = `${PS_ICONS[kind] || ''}${text ? `<span>${esc(text)}</span>` : ''}`;253+}254+255+// placePlayState puts the badge at the picture's bottom right (inside any256+// bars around it), or above the banner or the crawl: captionSizes'257+// rule for the captions.258+function placePlayState() {259+ const w = stage.clientWidth;260+ const h = stage.clientHeight;261+ if (!w || !h) return;262+ const ar = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 16 / 9;263+ const pw = Math.min(w, h * ar);264+ const ph = pw / ar;265+ let bottom = (h - ph) / 2 + ph * 0.045;266+ const banner = $('#banner');267+ if (state.view === 'tv' && banner.classList.contains('show')) bottom = Math.max(bottom, h - banner.offsetTop + 14);268+ const crawl = $('#crawl');269+ if (crawl && document.body.classList.contains('has-crawl')) bottom = Math.max(bottom, crawl.offsetHeight + 14);270+ ps.el.style.bottom = `${Math.round(bottom)}px`;271+ ps.el.style.right = `${Math.round((w - pw) / 2 + pw * 0.025)}px`;272+}273+274+function startWaiting() {275+ if (!ps.played || ps.waitTimer || ps.waiting) return;276+ ps.waitTimer = setTimeout(() => {277+ ps.waitTimer = 0;278+ ps.waiting = true;279+ renderPlayState();280+ }, PS_WAIT_MS);281+}282+283+function stopWaiting() {284+ clearTimeout(ps.waitTimer);285+ ps.waitTimer = 0;286+ if (ps.waiting) ps.waiting = false;287+ renderPlayState();288+}289+290+video.addEventListener('waiting', startWaiting);291+video.addEventListener('stalled', startWaiting);292+for (const ev of ['canplay', 'pause', 'play', 'seeked']) video.addEventListener(ev, stopWaiting);293+video.addEventListener('playing', () => { ps.played = true; stopWaiting(); });294+// Resuming a paused stream says where it resumes.295+video.addEventListener('pause', () => { if (ps.played) ps.paused = true; });296+video.addEventListener('play', () => {297+ if (!ps.paused) return;298+ ps.paused = false;299+ flashPlace();300+});301+// A new stream: its first wait for data is the tune, which has its static.302+video.addEventListener('emptied', () => {303+ ps.played = false;304+ ps.paused = false;305+ ps.skipUntil = 0;306+ ps.placeUntil = 0;307+ stopWaiting();308+});309+video.addEventListener('timeupdate', renderPlayState);310+video.addEventListener('resize', placePlayState);311+if (window.ResizeObserver) new ResizeObserver(placePlayState).observe(stage);312+new MutationObserver(() => { placePlayState(); renderPlayState(); }).observe($('#banner'), { attributes: true, attributeFilter: ['class'] });313+new MutationObserver(() => { placePlayState(); renderPlayState(); }).observe(document.body, { attributes: true, attributeFilter: ['class'] });314+new MutationObserver(renderPlayState).observe(stage, { attributes: true, attributeFilter: ['class'] });315+new MutationObserver(renderPlayState).observe($('#nosignal'), { attributes: true, attributeFilter: ['hidden'] });316+317+// ---------- media session ----------318+// What is on, for the system's media controls and keys: Now Playing on a319+// Mac, MPRIS on Linux (which WebKitGTK provides).320+const mediaSession = navigator.mediaSession;321+322+function wireMediaSession() {323+ if (!mediaSession) return;324+ const on = (action, fn) => {325+ try { mediaSession.setActionHandler(action, fn); } catch { /* not offered here */ }326+ };327+ on('play', () => { if (video.paused) togglePause(); });328+ on('pause', () => { if (!video.paused) togglePause(); });329+ on('nexttrack', () => step(1));330+ on('previoustrack', () => step(-1));331+ on('seekforward', () => skip(30));332+ on('seekbackward', () => skip(-10));333+}334+335+let mediaShown = '';336+function updateMediaSession() {337+ if (!mediaSession || !window.MediaMetadata) return;338+ const ch = state.current;339+ const it = state.recording;340+ let m = null;341+ if (it) {342+ m = { title: it.title, artist: 'Recording', album: it.subtitle || 'Airwaves', art: imageURL(it.image) };343+ } else if (ch && ch.weather) {344+ m = { title: 'Local Forecast', artist: `${ch.number} ${ch.name}`, album: 'Airwaves', art: ch.logo };345+ } else if (ch) {346+ const p = airingAt(ch, Date.now());347+ const name = ch.network || displayCall(ch);348+ m = { title: p ? p.title : name, artist: `${ch.number} ${name}`, album: (p && p.episodeTitle) || 'Airwaves', art: imageURL(p && p.image) || ch.logo };349+ }350+ const key = JSON.stringify(m);351+ if (key === mediaShown) return;352+ mediaShown = key;353+ mediaSession.metadata = m && new window.MediaMetadata({ title: m.title, artist: m.artist, album: m.album, artwork: m.art ? [{ src: m.art }] : [] });354+ updatePlaybackState();355+}356+357+function updatePlaybackState() {358+ if (!mediaSession) return;359+ const wx = state.current && state.current.weather && !state.recording;360+ mediaSession.playbackState = !state.current && !state.recording ? 'none' : wx || !video.paused ? 'playing' : 'paused';361+}
A frontend/dist/js/player.js +648 -0
@@ -0,0 +1,648 @@
1+'use strict';2+3+// Tuning and playing a channel, the banner and the on-screen controls.4+5+// ---------- tuning & playback ----------6+// at is when the viewer asked for the channel (a key press), for timing7+// the change.8+async function tune(ch, { quiet = false, at = performance.now() } = {}) {9+ if (!ch) return;10+ if (state.recording) saveRecordingProgress();11+ if (state.current && state.current.key !== ch.key) state.previous = state.current;12+ state.current = ch;13+ state.note = '';14+ state.recording = null;15+ state.staleStream = false;16+ const token = ++state.tuneToken;17+ if (!quiet) showBanner(); else renderBanner();18+ stage.classList.add('tuning');19+ $('#nosignal').hidden = true;20+ showWX(!!ch.weather);21+ if (ch.weather) {22+ resetPlayer();23+ api().StopTV();24+ setTimeout(() => stage.classList.remove('tuning'), 400);25+ return renderBanner();26+ }27+ // The server ends this screen's stream as it starts the next: the28+ // picture plays out what it has, without asking for more of it.29+ if (state.hls) state.hls.stopLoad();30+ let played = false;31+ try {32+ const pb = await api().Tune(ch.number);33+ if (token !== state.tuneToken) return;34+ state.note = pb.note || '';35+ await play(pb, token);36+ played = token === state.tuneToken;37+ if (played) reportTune(ch, performance.now() - at);38+ } catch (e) {39+ if (token !== state.tuneToken) return;40+ log('warn', `tune ${ch.number}: ${e}`);41+ showNoSignal(ch, String(e && e.message ? e.message : e));42+ // The server read the tuner while it tried.43+ if (!ch.own) loadSignal();44+ } finally {45+ // The static clears as the first frame shows.46+ if (played) onFirstFrame(() => { if (token === state.tuneToken) stage.classList.remove('tuning'); });47+ else if (token === state.tuneToken) setTimeout(() => stage.classList.remove('tuning'), 200);48+ }49+ renderBanner();50+}51+52+// onFirstFrame calls fn once the video's first frame is on screen, where53+// the browser can tell (requestVideoFrameCallback), else a moment after54+// playback starts; within a second either way.55+function onFirstFrame(fn) {56+ let done = false;57+ const once = () => { if (!done) { done = true; fn(); } };58+ if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(once);59+ setTimeout(once, video.requestVideoFrameCallback ? 1000 : 200);60+}61+62+// reportTune tells the server how long a channel change took, from the63+// key to the picture playing, for the times on its admin page.64+function reportTune(ch, ms) {65+ if (!api().ReportTune) return;66+ api().ReportTune({ number: ch.number, kind: ch.own ? ch.kind : 'antenna', firstFrameMs: Math.round(ms) }).catch(() => {});67+}68+69+function resetPlayer() {70+ state.liveBaseline = null;71+ if (state.hls) { state.hls.destroy(); state.hls = null; }72+ video.removeAttribute('src');73+ video.load();74+}75+76+async function play(pb, token) {77+ resetPlayer();78+ // hls.js first, so playback and captions work the same everywhere: it79+ // hands the app a stream's captions ahead of time, where macOS's own HLS80+ // gives them only as they show. Native HLS when there's no hls.js.81+ const tries = [];82+ if (window.Hls && window.Hls.isSupported()) tries.push(['hls.js', pb.url], ['hls.js', pb.path]);83+ if (video.canPlayType('application/vnd.apple.mpegurl')) tries.push(['native', pb.url]);84+ let last = 'no HLS support in this webview';85+ for (const [how, src] of tries) {86+ if (token !== state.tuneToken) return;87+ try {88+ await attempt(how, src);89+ log('info', `playing ${state.recording ? 'recording ' + state.recording.title : state.current.number} via ${how}`);90+ return;91+ } catch (e) {92+ last = `${how}: ${e.message || e}`;93+ log('warn', `${how} ${src}: ${last}`);94+ resetPlayer();95+ }96+ }97+ throw new Error(`Could not play the stream (${last})`);98+}99+100+function attempt(how, src) {101+ return new Promise((resolve, reject) => {102+ let settled = false;103+ const done = (err) => {104+ if (settled) return;105+ settled = true;106+ clearTimeout(timer);107+ video.removeEventListener('playing', onPlaying);108+ video.removeEventListener('error', onError);109+ if (err) reject(err); else resolve();110+ };111+ const onPlaying = () => done();112+ const onError = () => done(new Error((video.error && video.error.message) || `media error ${video.error && video.error.code}`));113+ const timer = setTimeout(() => done(new Error('timed out')), 15000);114+ video.addEventListener('playing', onPlaying);115+ video.addEventListener('error', onError);116+ if (how === 'native') {117+ video.src = src;118+ } else {119+ // hls.js keeps all it has played, up to WebKit's limit, for rewinding;120+ // lite mode keeps 90 s, and loads older video again from the server.121+ const h = new window.Hls({ liveSyncDurationCount: 2, maxBufferLength: 12, subtitleDisplay: false, ...(lite ? { backBufferLength: 90 } : {}) });122+ state.hls = h;123+ h.on(window.Hls.Events.ERROR, (_, d) => { if (d.fatal) done(new Error(d.details)); });124+ h.loadSource(src);125+ h.attachMedia(video);126+ }127+ video.play().catch((err) => {128+ if (err && err.name === 'NotAllowedError') {129+ video.muted = true;130+ video.play().catch(() => {});131+ const m = keyHint('m', 'M');132+ if (m) return toast(`Started muted. Press ${m} for sound.`, 3500, true);133+ // A remote has no M: its next key, which lets sound play, turns it on.134+ document.addEventListener('keydown', () => { if (!state.userMuted) video.muted = false; }, { once: true, capture: true });135+ toast('Started muted. Press any key for sound.', 3500);136+ }137+ });138+ });139+}140+141+function showNoSignal(ch, msg) {142+ resetPlayer();143+ const ns = $('#nosignal');144+ f(ns, 'num').textContent = ch.number;145+ f(ns, 'call').textContent = `${displayCall(ch)} ${ch.network ? '| ' + ch.network : ''}`;146+ f(ns, 'msg').textContent = msg;147+ ns.hidden = false;148+}149+150+function step(dir) {151+ const list = state.lineup;152+ if (!list.length) return;153+ let i = list.findIndex((c) => c.key === (state.current && state.current.key));154+ i = (i + dir + list.length) % list.length;155+ tune(list[i]);156+}157+158+// ---------- banner ----------159+function chipsFor(p, ch) {160+ const out = [];161+ for (const fl of (p && p.flags) || []) out.push(`<span class="chip ${fl === 'Live' ? 'live' : fl === 'New' ? 'new' : ''}">${esc(fl)}</span>`);162+ if (p && p.stars) out.push(`<span class="chip" title="Gracenote's rating, out of 4">${stars(p.stars)}</span>`);163+ if (p && p.rating) out.push(`<span class="chip">${esc(p.rating)}</span>`);164+ if (p && p.runtime) out.push(`<span class="chip">${p.runtime >= 60 ? `${Math.floor(p.runtime / 60)} h ${p.runtime % 60 ? `${p.runtime % 60} min` : ''}` : `${p.runtime} min`}</span>`);165+ for (const t of (p && p.tags) || []) out.push(`<span class="chip">${esc(t)}</span>`);166+ return out.join('');167+}168+169+// imageURL is a picture's address: the server's own ("/guide-art/...")170+// relative to the server, others as they are.171+const imageURL = (u) => (u && u.startsWith('/') ? state.boot.serverUrl + u : u || '');172+173+// stars draws a rating out of 4 in stars, halves included.174+const stars = (n) => '\u2605'.repeat(Math.floor(n)) + (n % 1 >= 0.5 ? '\u00bd' : '');175+176+function episodeLine(p) {177+ if (!p) return '';178+ const se = p.season && p.episode ? `S${p.season} E${p.episode}` : '';179+ // A repeat says when it first aired, which says the year too.180+ const first = p.originalAirDate && !(p.flags || []).includes('New') ? `first aired ${firstAired(p.originalAirDate)}` : '';181+ const game = p.awayTeam && p.homeTeam && !p.episodeTitle ? `${p.awayTeam} at ${p.homeTeam}` : '';182+ return [p.episodeTitle && `"${p.episodeTitle}"`, game, se, !first && p.year, first].filter(Boolean).join(' ');183+}184+185+// firstAired names an original air date ("2019-03-05") as "Mar 5, 2019".186+function firstAired(d) {187+ const [y, m, day] = d.split('-').map(Number);188+ return new Date(y, m - 1, day).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });189+}190+191+// castLine names a program's cast: "With Missy Peregrym, Zeeko Zaki".192+const castLine = (p, n = 5) => (p && p.cast && p.cast.length ? `With ${p.cast.slice(0, n).map((c) => c.replace(/ \(.*\)$/, '')).join(', ')}` : '');193+194+function renderBanner() {195+ updateMediaSession();196+ showTimeshift();197+ renderOSD();198+ const ch = state.current;199+ const b = $('#banner');200+ if (state.recording) return renderRecordingBanner(b, state.recording);201+ if (!ch) return;202+ if (ch.weather) return renderWXBanner(b, ch);203+ const now = Date.now();204+ const p = airingAt(ch, now);205+ const nx = p ? nextAfter(ch, p._e - 1) : nextAfter(ch, now);206+ f(b, 'num').textContent = ch.number;207+ f(b, 'num').classList.toggle('long', ch.number.length > 4);208+ f(b, 'call').textContent = displayCall(ch);209+ f(b, 'net').textContent = ch.network || '';210+ showChannelLogo(b, ch);211+ f(b, 'time').textContent = p ? `${clock(p._s)} to ${clock(p._e)}` : '';212+ // What's on is being recorded, or will be.213+ const rec = p && state.dvrKeys.get(airingKey(ch, p));214+ f(b, 'chips').innerHTML = (rec ? `<span class="chip live">${rec.status === 'recording' ? 'Recording' : 'Will record'}</span>` : '') + chipsFor(p, ch);215+ f(b, 'title').textContent = p ? p.title : 'No listings';216+ f(b, 'ep').textContent = episodeLine(p);217+ f(b, 'cast').textContent = castLine(p, 3);218+ f(b, 'progress').style.width = p ? `${Math.min(100, ((now - p._s) / (p._e - p._s)) * 100)}%` : '0';219+ f(b, 'desc').textContent = p ? p.description || '' : ch.description || '';220+ f(b, 'next').textContent = nx ? `${clock(nx._s)} ${nx.title}` : '';221+222+ f(b, 'note').textContent = state.note || '';223+224+ // The server's own channels have no reception to show. The meter is225+ // rebuilt on the next antenna channel.226+ if (ch.custom) {227+ b.classList.remove('warn');228+ f(b, 'meter').hidden = true;229+ f(b, 'tier').textContent = ch.category && ch.category !== 'Other' ? ch.category : '';230+ f(b, 'tier').style.color = '';231+ f(b, 'tx').textContent = OWN_SOURCE[ch.kind] || OWN_SOURCE.folder;232+ f(b, 'atsc3').textContent = '';233+ return;234+ }235+236+ // The signal is shown when asked for (I twice, or the controls' I) or237+ // when what was measured explains a bad picture.238+ const sig = sigOf(sigFor(ch));239+ b.classList.toggle('warn', trouble(sig));240+ f(b, 'meter').outerHTML = meter(sig, true).replace('class="meter big', 'data-f="meter" class="meter big');241+ f(b, 'meter').hidden = false;242+ f(b, 'tier').textContent = sig.label;243+ f(b, 'tier').style.color = sig.state === 'none' ? '' : sig.color;244+ const st = stationFor(ch);245+ const mux = state.byRF.get(ch.rf);246+ const where = ch.rf ? `RF ${ch.rf} ${(mux && mux.band) || (st && st.band) || ''}`.trim() : '';247+ const lines = [];248+ if (st) {249+ lines.push(`${ch.via ? `On ${esc(st.callSign)}'s transmitter, ` : `${esc(st.callSign)} `}${where}`);250+ lines.push(`${st.distanceKm.toFixed(0)} km ${compass(st.bearingDeg)}, ${st.erpKw >= 10 ? st.erpKw.toFixed(0) : st.erpKw.toFixed(1)} kW`);251+ } else if (where) {252+ lines.push(`${where}, no licensed transmitter on record`);253+ }254+ lines.push(esc(sigDetail(sig)));255+ f(b, 'tx').innerHTML = lines.join('<br>');256+ f(b, 'atsc3').textContent = ch.atsc3 ? `Also in ATSC 3.0 on ${ch.atsc3.hostCall} RF ${ch.atsc3.rf}` : '';257+}258+259+// What the server's own channels play, for the banner's details.260+const OWN_SOURCE = {261+ folder: 'Videos from your server, on a loop',262+ jellyfin: 'From your Jellyfin library, on a schedule',263+ youtube: 'YouTube uploads, streamed as they air',264+};265+266+// showChannelLogo puts an own channel's logo under its number in the267+// banner; stations' logos are for the guide.268+function showChannelLogo(b, ch) {269+ const el = f(b, 'logo');270+ if (!el) return;271+ const logo = ch && ch.own && !state.recording ? ch.logo : '';272+ el.hidden = !logo;273+ el.style.backgroundImage = logo ? `url('${logo}')` : '';274+}275+276+// The banner shows briefly on a channel change and on request. Pressing I277+// cycles: banner, banner with the measured signal, hidden. With the278+// on-screen controls up it stays as long as they do.279+function showBanner(detail = false) {280+ renderBanner();281+ const b = $('#banner');282+ b.classList.toggle('detail', detail);283+ b.classList.add('show');284+ if (osd.open) return osdTouch();285+ return hideBannerSoon(detail ? 12000 : 4000);286+}287+288+// hideBanner puts the banner away, and the controls with it.289+function hideBanner() {290+ clearTimeout(state.bannerTimer);291+ closeOSD();292+ $('#banner').classList.remove('show', 'detail');293+}294+295+function hideBannerSoon(ms = 4000) {296+ clearTimeout(state.bannerTimer);297+ if (osd.open) return osdTouch();298+ state.bannerTimer = setTimeout(hideBanner, ms);299+ return undefined;300+}301+302+// cycleBanner is the info key. It also flashes where the picture is, in303+// the playback badge. With the controls up it shows or hides reception.304+function cycleBanner() {305+ const b = $('#banner');306+ if (osd.open) {307+ if (!state.recording) b.classList.toggle('detail');308+ return flashPlace();309+ }310+ if (b.classList.contains('show') && (b.classList.contains('detail') || state.recording)) return hideBanner();311+ flashPlace();312+ return showBanner(b.classList.contains('show'));313+}314+315+// ---------- on-screen controls ----------316+// OK on live TV brings up the controls over the picture, as video players317+// on a TV do (Media3's PlayerControlView, Leanback, Kodi's OSD, the Live318+// TV app's menu): the channel banner, a timeline of the program and of what319+// can be rewound, a row of labeled buttons, and the dock above as the way320+// to every view. The focus starts on Guide, so OK twice opens the guide;321+// Left and Right on live TV skip and bring them up with the timeline322+// picked. Up and Down move between the rows, Left and Right along one, OK323+// presses. They go after OSD_MS without a key, but stay while paused (as324+// Media3's and Leanback's do); Back puts them away.325+const OSD_MS = 6000;326+const osd = { open: false, timer: 0, tick: 0, last: 'guide', lastButton: 'guide', hover: false };327+328+// The buttons, in order: [action, label]. Labels change with the state.329+const OSD_BUTTONS = [330+ ['guide', 'Guide'], ['play', 'Pause'], ['back', '-10 s'], ['ahead', '+30 s'], ['live', 'Live'],331+ ['captions', 'Captions'], ['audio', 'Audio'], ['record', 'Record'], ['favorite', 'Favorite'], ['last', 'Last channel'],332+];333+const OSD_KEYS = { guide: 'G', play: 'Space', back: 'Left', ahead: 'Right', live: 'End', captions: 'C', audio: 'V', record: 'R', favorite: 'Shift F', last: 'L' };334+const ICON_TEXT = 'text-anchor="middle" font-family="Plex Mono, Menlo, monospace" font-weight="700"';335+const OSD_ICONS = {336+ guide: '<path d="M3 4h18v3.5H3zm0 6.25h7.5v3.5H3zm9.5 0H21v3.5h-8.5zM3 16.5h11V20H3zm13 0h5V20h-5z"/>',337+ play: '<path d="M7 4.5v15L19.5 12z"/>',338+ pause: '<path d="M6 4.5h4.2v15H6zm7.8 0H18v15h-4.2z"/>',339+ back: `<path d="M12 5.5a7.5 7.5 0 1 1-7.5 7.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M12 1.5v8l-5-4z"/><text x="12.4" y="16.2" font-size="7.2" ${ICON_TEXT}>10</text>`,340+ ahead: `<path d="M12 5.5a7.5 7.5 0 1 0 7.5 7.5" fill="none" stroke="currentColor" stroke-width="2.2"/><path d="M12 1.5v8l5-4z"/><text x="11.6" y="16.2" font-size="7.2" ${ICON_TEXT}>30</text>`,341+ live: '<circle cx="12" cy="12" r="4.2"/><path d="M6.6 6.6a7.6 7.6 0 0 0 0 10.8M17.4 6.6a7.6 7.6 0 0 1 0 10.8" fill="none" stroke="currentColor" stroke-width="2"/>',342+ tv: '<rect x="3" y="6.5" width="18" height="12.5" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 2.5l4 3.6 4-3.6" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="12" cy="12.75" r="2.4"/>',343+ captions: `<rect x="2.5" y="5" width="19" height="14" rx="2.5" fill="none" stroke="currentColor" stroke-width="2"/><text x="12" y="15.1" font-size="7.6" ${ICON_TEXT}>CC</text>`,344+ audio: '<path d="M3 9h4l5-4.5v15L7 15H3z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.2 5.8a8.8 8.8 0 0 1 0 12.4" fill="none" stroke="currentColor" stroke-width="2"/>',345+ record: '<circle cx="12" cy="12" r="7"/>',346+ favorite: '<path d="M12 3.6l2.5 5.4 5.9.6-4.4 4 1.3 5.8L12 16.4l-5.3 3 1.3-5.8-4.4-4 5.9-.6z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>',347+ favorited: '<path d="M12 3.6l2.5 5.4 5.9.6-4.4 4 1.3 5.8L12 16.4l-5.3 3 1.3-5.8-4.4-4 5.9-.6z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>',348+ last: '<path d="M8 3.5 3 8l5 4.5V9.3h9V6.7H8zM16 11.5v3.2H7v2.6h9v3.2l5-4.5z"/>',349+};350+351+const timelineEl = () => f($('#banner'), 'timeline');352+const osdButtons = () => $$('#banner .osd-bar .ob').filter((b) => !b.hidden);353+354+function wireOSD() {355+ const b = $('#banner');356+ f(b, 'osd').innerHTML = OSD_BUTTONS.map(([act, label]) => `<button type="button" class="ob fx" data-act="${act}" title="${esc(label)} (${OSD_KEYS[act]})"><svg viewBox="0 0 24 24" aria-hidden="true"></svg><span>${esc(label)}</span></button>`).join('');357+ f(b, 'osd').addEventListener('click', (e) => {358+ const btn = e.target.closest('[data-act]');359+ if (!btn) return;360+ osdTouch();361+ osdAct(btn.dataset.act);362+ });363+ b.addEventListener('focusin', (e) => {364+ const btn = e.target.closest('.ob');365+ if (btn) osd.last = osd.lastButton = btn.dataset.act;366+ else if (e.target === timelineEl()) osd.last = 'timeline';367+ });368+ // The pointer on the controls keeps them up.369+ b.addEventListener('mouseenter', () => { osd.hover = document.body.classList.contains('mouse-active'); osdTouch(); });370+ b.addEventListener('mouseleave', () => { osd.hover = false; osdTouch(); });371+ // A click on the timeline goes there.372+ timelineEl().addEventListener('click', (e) => {373+ const bar = $('.tl-bar', timelineEl()).getBoundingClientRect();374+ if (bar.width > 0) seekTimeline((e.clientX - bar.left) / bar.width);375+ });376+}377+378+// openOSD brings up the controls, with the focus on focus ("guide", another379+// button's action, or "timeline"), or on none for a pointer.380+function openOSD(focus = 'guide') {381+ if (state.view !== 'tv' || (!state.current && !state.recording)) return;382+ if (!osd.open) {383+ osd.open = true;384+ document.body.classList.add('osd-open');385+ clearTimeout(state.bannerTimer);386+ osd.tick = setInterval(renderTimeline, 1000);387+ }388+ renderBanner();389+ $('#banner').classList.add('show', 'osd');390+ if (focus) osdFocus(focus);391+ osdTouch();392+}393+394+function closeOSD() {395+ if (!osd.open) return;396+ osd.open = false;397+ clearTimeout(osd.timer);398+ clearInterval(osd.tick);399+ osd.tick = 0;400+ osd.hover = false;401+ document.body.classList.remove('osd-open');402+ const b = $('#banner');403+ b.classList.remove('osd', 'tl-on');404+ if (state.dock >= 0 && state.view === 'tv') closeDock(false);405+ blurIn(b);406+}407+408+// osdTouch starts the controls' time over: a key was pressed.409+function osdTouch() {410+ clearTimeout(osd.timer);411+ if (!osd.open || osd.hover || pausedStream()) return;412+ osd.timer = setTimeout(hideBanner, OSD_MS);413+}414+415+const hasPicture = () => !!(video.src || state.hls) && !video.hidden && $('#nosignal').hidden;416+const pausedStream = () => hasPicture() && video.paused;417+418+// osdFocus focuses a button by its action, or the timeline: the nearest419+// there is when that one isn't showing.420+function osdFocus(target) {421+ const tl = timelineEl();422+ if (target === 'timeline' && !tl.hidden) return focusEl(tl);423+ const buttons = osdButtons();424+ const want = target === 'timeline' ? osd.lastButton : target;425+ return focusEl(buttons.find((b) => b.dataset.act === want) || buttons.find((b) => b.dataset.act === 'play') || buttons[0]);426+}427+428+// osdKey handles a key while the controls are up; it reports whether it429+// did. Keys it leaves (letters, Space, channel up and down) do what they430+// do on TV.431+function osdKey(e) {432+ const k = e.key;433+ const el = document.activeElement;434+ const onTl = el === timelineEl();435+ const btn = el && el.closest ? el.closest('#banner .ob') : null;436+ if ((k === 'ArrowLeft' || k === 'ArrowRight') && e.shiftKey) {437+ e.preventDefault();438+ skip(k === 'ArrowLeft' ? -60 : 60);439+ renderTimeline();440+ return true;441+ }442+ if (k === 'Escape') {443+ // A channel number being typed goes first.444+ e.preventDefault();445+ if (state.entry) cancelEntry(); else hideBanner();446+ return true;447+ }448+ if (!onTl && !btn) {449+ // Up by a pointer, or the focus went: the first key finds it.450+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(k)) return false;451+ e.preventDefault();452+ osdFocus(osd.last);453+ return true;454+ }455+ switch (k) {456+ case 'ArrowLeft': case 'ArrowRight':457+ if (onTl) {458+ skip(k === 'ArrowLeft' ? -10 : 30);459+ renderTimeline();460+ } else {461+ focusEl(neighbor(osdButtons(), btn, k === 'ArrowLeft' ? -1 : 1));462+ }463+ break;464+ case 'ArrowUp':465+ if (btn && !timelineEl().hidden) focusEl(timelineEl());466+ else openDock();467+ break;468+ case 'ArrowDown':469+ if (onTl) osdFocus(osd.lastButton);470+ break;471+ case 'Enter':472+ if (onTl) togglePause(); else btn.click();473+ break;474+ default: return false;475+ }476+ e.preventDefault();477+ return true;478+}479+480+function osdAct(act) {481+ const after = (x) => Promise.resolve(x).then(renderOSD);482+ switch (act) {483+ case 'guide': return setView('guide');484+ case 'play': return togglePause();485+ case 'back': skip(-10); return renderTimeline();486+ case 'ahead': skip(30); return renderTimeline();487+ case 'live': return state.recording ? state.current && tune(state.current) : goLive();488+ case 'captions': return after(toggleCaptions());489+ case 'audio': return after(cycleAudio());490+ case 'record': return after(recordNow('once'));491+ case 'favorite': return after(toggleFavorite(state.current));492+ case 'last': return state.previous && tune(state.previous);493+ default: return null;494+ }495+}496+497+// renderOSD shows the buttons that apply now, labeled for the state, and498+// the timeline. The focus moves on from a button that went.499+function renderOSD() {500+ if (!osd.open) return;501+ const b = $('#banner');502+ const ch = state.current;503+ const rec = state.recording;504+ const now = Date.now();505+ // Video controls for a stream, not the weather channel's display or a506+ // channel that failed to tune.507+ const stream = $('#nosignal').hidden && !!(rec || (ch && !ch.weather));508+ const p = !rec && ch && !ch.own ? airingAt(ch, now) : null;509+ const it = p && state.dvrKeys.get(airingKey(ch, p));510+ const fav = !rec && isFav(ch);511+ const was = document.activeElement;512+ const before = osdButtons();513+ const at = before.indexOf(was);514+ const set = (act, on, label, icon = act, lit = false) => {515+ const btn = $(`.ob[data-act="${act}"]`, b);516+ btn.hidden = !on;517+ if (!on) return;518+ const span = btn.lastElementChild;519+ if (span.textContent !== label) span.textContent = label;520+ if (btn.dataset.icon !== icon) {521+ btn.dataset.icon = icon;522+ btn.firstElementChild.innerHTML = OSD_ICONS[icon];523+ }524+ btn.classList.toggle('on', lit);525+ if (act !== 'guide' && act !== 'last') btn.setAttribute('aria-pressed', String(lit));526+ };527+ set('guide', true, 'Guide');528+ set('play', stream, video.paused && hasPicture() ? 'Play' : 'Pause', video.paused && hasPicture() ? 'play' : 'pause');529+ set('back', stream, '-10 s');530+ set('ahead', stream, '+30 s');531+ set('live', !!rec || stream, rec ? 'Live TV' : 'Live', rec ? 'tv' : 'live');532+ set('captions', stream, 'Captions', 'captions', stream && captionsWanted());533+ set('audio', stream && audioTracks().length > 1, 'Audio');534+ // Recording is for the antenna's channels, on a server with a DVR.535+ set('record', !!(state.info.dvr && p && p._e > now), it ? (it.status === 'recording' ? 'Recording' : 'Will record') : 'Record', 'record', !!it);536+ set('favorite', !rec && !!ch, 'Favorite', fav ? 'favorited' : 'favorite', fav);537+ set('last', !rec && !!state.previous, 'Last channel');538+ const last = $('.ob[data-act="last"]', b);539+ last.title = state.previous ? `Back to ${state.previous.number} ${displayCall(state.previous)} (L)` : '';540+ renderTimeline();541+ if (was && b.contains(was) && (was.hidden || document.activeElement !== was)) {542+ const after = osdButtons();543+ if (was === timelineEl() || at < 0) osdFocus(osd.lastButton);544+ else focusEl(after[Math.min(at, after.length - 1)]);545+ }546+}547+548+// timelineModel is what the timeline shows, in one scale: for live TV, ms549+// since the epoch, from the program's start (or whatever can be rewound550+// before it) to its end; for a recording, seconds into it.551+function timelineModel() {552+ if (!hasPicture()) return null;553+ const r = video.seekable;554+ const has = r && r.length > 0;555+ if (state.recording) {556+ const off = state.recOffset || 0;557+ const pos = off + (video.currentTime || 0);558+ const dur = Math.max(state.recording.duration || 0, pos);559+ if (!dur) return null;560+ return {561+ lo: 0, hi: dur, past: 0, edge: null, head: pos, bufLo: has ? off + r.start(0) : pos, bufHi: has ? off + r.end(r.length - 1) : pos,562+ left: mmss(pos), right: mmss(dur), at: mmss(pos), live: false,563+ };564+ }565+ const now = Date.now();566+ const behind = behindLive();567+ const head = now - behind * 1000;568+ const bufLo = has ? now - Math.max(0, livePoint() - r.start(0)) * 1000 : head;569+ const p = airingAt(state.current, now);570+ const lo = Math.min(p ? p._s : now - 15 * MIN, bufLo, head);571+ const hi = p ? p._e : now;572+ const live = behind <= 4;573+ return { lo, hi, past: now, edge: now, head, bufLo, bufHi: now, left: clock(lo), right: p ? clock(hi) : 'Now', at: live ? 'Live' : `-${mmss(behind)}`, live };574+}575+576+function renderTimeline() {577+ if (!osd.open) return;578+ const b = $('#banner');579+ const tl = timelineEl();580+ const m = timelineModel();581+ const show = !!m;582+ if (tl.hidden === show) {583+ // The timeline goes (a failed tune, the weather channel): its focus584+ // moves down to the buttons.585+ const had = document.activeElement === tl;586+ tl.hidden = !show;587+ if (had && !show) osdFocus(osd.lastButton);588+ }589+ b.classList.toggle('tl-on', show);590+ const liveBtn = $('.ob[data-act="live"]', b);591+ if (liveBtn) liveBtn.classList.toggle('at', !!(m && m.live));592+ if (!m) return;593+ const span = m.hi - m.lo || 1;594+ const pct = (t) => `${Math.max(0, Math.min(100, ((t - m.lo) / span) * 100)).toFixed(2)}%`;595+ const bar = $('.tl-bar', tl);596+ const [past, buf, edge, head] = bar.children;597+ past.style.width = pct(m.past);598+ buf.style.left = pct(m.bufLo);599+ buf.style.width = `calc(${pct(m.bufHi)} - ${pct(m.bufLo)})`;600+ edge.hidden = m.edge == null;601+ if (m.edge != null) edge.style.left = pct(m.edge);602+ head.style.left = pct(m.head);603+ f(tl, 'tl-lo').textContent = m.left;604+ f(tl, 'tl-hi').textContent = m.right;605+ f(tl, 'tl-at').textContent = m.at;606+ tl.setAttribute('aria-valuetext', m.at);607+}608+609+// seekTimeline goes to a fraction of the timeline (a click on it).610+function seekTimeline(frac) {611+ const m = timelineModel();612+ const r = video.seekable;613+ if (!m || !r || !r.length) return;614+ const t = m.lo + Math.max(0, Math.min(1, frac)) * (m.hi - m.lo);615+ // Both scales count forward with the picture: ms for live, s for a recording.616+ const delta = state.recording ? t - m.head : (t - m.head) / 1000;617+ const from = video.currentTime;618+ video.currentTime = Math.min(Math.max(from + delta, r.start(0)), state.recording ? r.end(r.length - 1) : livePoint());619+ showTimeshift();620+ renderTimeline();621+ osdTouch();622+}623+624+// ---------- number entry ----------625+function entryKey(k) {626+ state.entry = (state.entry + k).slice(0, 6);627+ const e = $('#entry');628+ e.textContent = state.entry;629+ e.classList.add('show');630+ clearTimeout(state.entryTimer);631+ state.entryTimer = setTimeout(commitEntry, 2200);632+}633+634+function commitEntry() {635+ clearTimeout(state.entryTimer);636+ const n = state.entry;637+ state.entry = '';638+ $('#entry').classList.remove('show');639+ if (!n) return;640+ const ch = findChannel(n);641+ if (ch) tune(ch); else toast(`No channel ${n} in your lineup`);642+}643+644+function cancelEntry() {645+ clearTimeout(state.entryTimer);646+ state.entry = '';647+ $('#entry').classList.remove('show');648+}
A frontend/dist/js/reception.js +493 -0

Not shown: this page already shows 6000 lines of diff.

A frontend/dist/js/recordings.js +375 -0

Not shown: this page already shows 6000 lines of diff.

A frontend/dist/js/settings.js +669 -0

Not shown: this page already shows 6000 lines of diff.

A frontend/dist/js/weather.js +294 -0

Not shown: this page already shows 6000 lines of diff.

M frontend/dist/style.css +12 -12

Not shown: this page already shows 6000 lines of diff.

M frontend/dist/web.js +3 -3

Not shown: this page already shows 6000 lines of diff.

M frontend/embed_test.go +24 -7

Not shown: this page already shows 6000 lines of diff.

M internal/api/tv_test.go +5 -5

Not shown: this page already shows 6000 lines of diff.

M scripts/tunebench.mjs +1 -1

Not shown: this page already shows 6000 lines of diff.