1;23// More: Settings and its tabs, what the rows do, the interface size, favorites and hidden channels.45// ---------- settings ----------6// More is Settings: a tab for each area in the rail, and beside it the7// tab's rows, or Reception's measurements. For a remote or a controller8// first (a mouse and a keyboard work too). On the tabs Up and Down pick9// one, Right or OK goes into it. In a list Up and Down move, Left and Right10// change a choice or a switch, Enter switches, runs or opens a list (hidden11// channels, the controls, data sources), and Left on a row with nothing to12// change goes back to the tabs. Page Up and Down (a controller's LB and13// RB) change tab anywhere, Up from the top moves to the dock, and Esc goes14// back. Changes are saved as they're made. The server's address and token15// need a keyboard: Enter on their row puts the cursor in them.16const settingsUI = { tab: 'display', zone: 'tabs', at: 0, sub: null, from: 0, rows: [], pending: {}, timers: {}, tested: '' };1718// SETTINGS_TABS are the areas in the rail's order, [id, label]; a row's19// section is its tab's label.20const SETTINGS_TABS = [['display', 'Display'], ['watching', 'Watching'], ['channels', 'Channels'], ['reception', 'Reception'],21 ['recordings', 'Recordings'], ['podcasts', 'Podcasts'], ['server', 'Server'], ['about', 'About']];2223const AUDIO_LANGS = [['', 'As broadcast'], ['en', 'English'], ['es', 'Spanish'], ['fr', 'French'], ['ko', 'Korean'],24 ['vi', 'Vietnamese'], ['zh', 'Chinese'], ['ru', 'Russian'], ['pt', 'Portuguese']];25const GUIDE_HOURS = [6, 12, 24, 36, 48, 72];26const WATCHED = [[0, 'Keep them'], [1, 'Delete after a day'], [7, 'Delete after a week'], [30, 'Delete after a month']];27const SETTINGS_LISTS = { hidden: 'Hidden channels', controls: 'Remote, controller and keys', sources: 'Data sources', podfound: 'Shows found' };28// How to hide a channel, with the keys there are.29const HIDE_HOW = () => (onRemote() ? 'In the guide, hold OK on a channel\'s program: Hide channel' : 'In the guide, H hides a channel');3031// padNames names face buttons ("A, Y") as the pad in use labels them, by32// their place in the standard mapping.33const FACES = {34 xbox: ['A', 'B', 'X', 'Y'], xboxswap: ['A', 'B', 'Y', 'X'], switch: ['B', 'A', 'Y', 'X'],35 playstation: ['Cross', 'Circle', 'Square', 'Triangle'], steamdeck: ['A', 'B', 'X', 'Y'],36};37const padNames = (names) => names.replace(/\b[ABXY]\b/g, (n) => (FACES[prompts.family] || FACES.xbox)['ABXY'.indexOf(n)]);3839// CONTROLS is the legend of the controls list: glyph keys, the controller's40// buttons by name (for a keyboard user), the keys, and what they do.41const CONTROLS = [42 ['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'],43 ['Enter', 'A', 'Enter', 'Choose. On TV, Enter (the remote\'s OK) brings up the controls, Guide first, and A plays and pauses'],44 ['Escape', 'B', 'Esc, Back', 'Back, and puts the controls away'],45 ['g', 'Y', 'G', 'Guide'],46 ['i', 'X', 'I', 'Program info, twice for the measured signal. In the guide, more for a program: record, favorite, hide'],47 ['PageUp PageDown', 'LB, RB', 'Page Up, Page Down', 'Channel down or up, a page of the guide or of Settings'],48 ['ShiftLeft ShiftRight', 'LT, RT', 'Shift Left, Shift Right', 'Back or ahead a minute'],49 ['Space', 'R3', 'Space, Play', 'Pause'],50 ['m', 'L3', 'M', 'Mute'],51 [',', 'Start', ',', 'Settings'],52 ['w', 'Back', 'W', 'Weather'],53];5455function wireSettings() {56 const v = $('#settings');57 const list = f(v, 'list');58 list.addEventListener('click', (e) => {59 const el = e.target.closest('.s-row');60 if (!el) return;61 const i = Number(el.dataset.i);62 if (i !== settingsUI.at) moveSetting(i, false);63 if (e.target.closest('input')) return;64 const r = settingsUI.rows[i];65 const step = e.target.closest('[data-step]');66 if (r.kind === 'choice') stepSetting(r, step ? Number(step.dataset.step) : 1, !step);67 else activateSetting(r);68 });69 // The text rows: Enter keeps what was typed, Esc puts it back.70 list.addEventListener('keydown', (e) => {71 const input = e.target.closest('input');72 if (!input) return;73 if (e.key === 'Enter') { e.preventDefault(); input.blur(); }74 if (e.key === 'Escape') {75 e.preventDefault();76 e.stopPropagation();77 input.value = input.dataset.was;78 input.blur();79 }80 });81 list.addEventListener('change', (e) => {82 if (e.target.name === 'server' || e.target.name === 'token') saveServer(e.target.name, e.target.value);83 if (e.target.name === 'podsearch') searchShows(e.target.value);84 });85 list.addEventListener('focusout', () => setTimeout(renderSettings));86 f(v, 'tabs').addEventListener('click', (e) => {87 const b = e.target.closest('[data-tab]');88 if (b) pickSettingsTab(b.dataset.tab, 'list');89 });90}9192// settingsTabs are the tabs there are: Reception with an antenna, and93// those with rows.94function settingsTabs() {95 const sections = new Set(settingsRows().map((r) => r.section));96 return SETTINGS_TABS.filter(([k, label]) => (k === 'reception' ? !!state.info && state.info.antenna !== false : sections.has(label)));97}9899const receptionShown = () => state.view === 'settings' && settingsUI.tab === 'reception';100101// openSettings shows a tab, the one left open when none is asked for, with102// the focus on the tabs or in the tab (zone 'list').103function openSettings(tab = settingsUI.tab, zone = 'tabs') {104 settingsUI.sub = null;105 pickSettingsTab(tab, zone);106}107108function pickSettingsTab(tab, zone = settingsUI.zone) {109 const tabs = settingsTabs();110 if (!tabs.some(([k]) => k === tab)) tab = tabs.length ? tabs[0][0] : 'display';111 if (tab !== settingsUI.tab) {112 settingsUI.at = 0;113 settingsUI.sub = null;114 }115 settingsUI.tab = tab;116 const reception = tab === 'reception';117 if (tab === 'podcasts' && Date.now() - podUI.at > MIN) loadPodcasts();118 $('#settings').dataset.tab = tab;119 f($('#settings'), 'list').hidden = reception;120 $('#antenna').hidden = !reception;121 renderSettings();122 if (reception) {123 renderAntenna();124 signalAt = 0; // fresh measurements125 if (zone === 'list') antUI.zone = antRows().length ? 'table' : 'head';126 } else f($('#settings'), 'list').scrollTop = 0;127 settingsZone(zone);128}129130// settingsZone puts the focus on the tabs, or in the tab.131function settingsZone(zone) {132 settingsUI.zone = zone;133 $('#settings').dataset.zone = zone;134 if (state.dock >= 0 || state.view !== 'settings') return;135 if (zone === 'tabs') {136 blurIn($('#settings .s-main'));137 focusEl($(`#settings [data-tab="${settingsUI.tab}"]`));138 } else if (settingsUI.tab === 'reception') {139 blurIn($('#settings .rail'));140 antZone(antUI.zone);141 } else {142 blurIn($('#settings .rail'));143 moveSetting(settingsUI.at);144 }145 renderSettingsHint();146}147148function settingsRefocus() {149 if (state.view !== 'settings' || state.dock >= 0) return;150 settingsZone(settingsUI.zone);151}152153// stepSettingsTab moves to the tab before or after, staying in tabs or154// in the tab.155function stepSettingsTab(dir) {156 const tabs = settingsTabs();157 const i = tabs.findIndex(([k]) => k === settingsUI.tab);158 const next = tabs[Math.max(0, Math.min(tabs.length - 1, i + dir))];159 if (next && next[0] !== settingsUI.tab) pickSettingsTab(next[0]);160}161162const settingsValue = (r) => (r.id in settingsUI.pending ? settingsUI.pending[r.id] : r.get());163164// settingsRows lists what can be set, by section. A row is a choice (Left165// and Right), a switch, an action, a list it opens, text, or information.166function settingsRows() {167 const s = state.settings;168 const info = state.info || {};169 const rows = [];170 const add = (section, row) => rows.push({ section, ...row });171172 add('Display', {173 id: 'scale', kind: 'choice', label: 'Interface size', sub: 'Larger reads better across a room',174 choices: SCALES.map((x) => [x, x ? `${x}%` : `Auto, ${autoScale()}%`]), get: () => s.scale || 0, set: setScale,175 });176 const auto = PLATFORMS.find(([k]) => k === detectedPlatform)[1];177 add('Display', {178 id: 'platform', kind: 'choice', label: 'Layout', subFor: (k) => PLATFORM_SUBS[k || detectedPlatform],179 choices: [['', `Auto, ${auto}`], ...PLATFORMS], get: () => localStorage.getItem(platformKey) || '', set: setPlatform,180 });181 const detected = PAD_LABELS.find(([k]) => k === prompts.detected)[1];182 add('Display', {183 id: 'padlabels', kind: 'choice', label: 'Button labels', sub: 'How hints name a controller\'s buttons',184 choices: PAD_LABELS.map(([k, label]) => [k, k ? label : `Auto, ${detected}`]), get: () => localStorage.getItem(padLabelsKey) || '', set: setPadLabels,185 });186187 const hours = (state.config && state.config.guideHours) || 24;188 add('Watching', {189 id: 'guideHours', kind: 'choice', delay: true, label: 'Listings', sub: 'How far ahead the guide goes',190 choices: [...new Set([...GUIDE_HOURS, hours])].sort((a, b) => a - b).map((h) => [h, `${h} hours`]), get: () => hours, set: setGuideHours,191 });192 add('Watching', {193 id: 'captions', kind: 'switch', label: 'Captions', sub: 'Programs that aren\'t in English start with them on',194 get: () => !!s.captions, set: setCaptionsDefault,195 });196 const lang = s.audioLang || '';197 add('Watching', {198 id: 'audioLang', kind: 'choice', label: 'Audio language', sub: 'When a channel has more than one',199 choices: AUDIO_LANGS.some(([k]) => k === lang) ? AUDIO_LANGS : [...AUDIO_LANGS, [lang, lang.toUpperCase()]], get: () => lang, set: setAudioLang,200 });201202 if (info.dvr) {203 add('Recordings', {204 id: 'watched', kind: 'choice', delay: true, label: 'Watched recordings', sub: 'Unwatched ones are always kept',205 choices: WATCHED, get: () => (state.dvr && state.dvr.prefs && state.dvr.prefs.deleteWatchedAfterDays) || 0, set: setWatched,206 });207 }208209 podcastRows(add);210211 const hidden = (s.hidden || []).length;212 add('Channels', { id: 'hidden', kind: 'open', label: 'Hidden channels', sub: HIDE_HOW(), value: hidden ? `${hidden} hidden` : 'None', open: 'hidden' });213 add('Channels', { id: 'hideshop', kind: 'action', verb: 'hides them', label: 'Hide shopping channels', sub: 'QVC, HSN, Jewelry TV and the like', value: 'Hide', run: hideShopping });214215 // In a browser the server is the one the page came from (web.js).216 const typeIt = androidTV ? 'OK brings up the keyboard' : 'Type it with a keyboard';217 if (state.boot && state.boot.fixedServer) add('Server', { id: 'server', kind: 'info', label: 'Address', sub: 'Where this page comes from', value: s.server || '' });218 else add('Server', { id: 'server', kind: 'text', label: 'Address', sub: typeIt, name: 'server', text: s.server || '', placeholder: 'nas.local' });219 add('Server', { id: 'token', kind: 'text', label: 'Token', sub: `If the server needs one. ${typeIt}`, name: 'token', text: s.token || '', password: true });220 add('Server', {221 id: 'test', kind: 'action', verb: 'tests it', label: 'Test connection',222 value: settingsUI.tested || (info.name ? `Connected to ${info.name}` : 'Test'), run: testServer,223 });224 add('Server', { id: 'refresh', kind: 'action', verb: 'refreshes', label: 'Refresh all data', sub: 'Transmitters and listings, fetched again', value: 'Refresh', run: refreshAll });225226 add('About', { id: 'app', kind: 'info', label: 'Airwaves', value: state.boot && state.boot.version ? `Version ${state.boot.version}` : '' });227 if (info.name) {228 add('About', { id: 'server-info', kind: 'info', label: 'Server', value: info.name, sub: [info.version && `Version ${info.version}`, s.server].filter(Boolean).join(', ') });229 }230 if (info.antenna !== false && info.tuner) {231 const t = info.tuner;232 const what = t.tuners ? `${t.tuners} tuner${t.tuners > 1 ? 's' : ''}, ${(t.standards || []).join(', ')}` : 'No tuner yet';233 add('About', { id: 'tuner', kind: 'info', label: 'Tuner', value: t.tuners ? tunerName(t) : what, sub: [t.tuners && what, t.model].filter(Boolean).join(', ') });234 }235 add('About', { id: 'controls', kind: 'open', label: SETTINGS_LISTS.controls, open: 'controls' });236 if (state.report && state.report.sources.length) add('About', { id: 'sources', kind: 'open', label: SETTINGS_LISTS.sources, value: `${state.report.sources.length}`, open: 'sources' });237 for (const [i, w] of ((state.report && state.report.warnings) || []).entries()) add('About', { id: `warning${i}`, kind: 'info', label: 'Note', sub: w });238 return rows;239}240241// settingsList is the rows of a list opened from a row.242function settingsList(name) {243 const title = SETTINGS_LISTS[name];244 const rows = [];245 const add = (row) => rows.push({ section: title, ...row });246 if (name === 'hidden') {247 const known = [...state.custom, ...state.antennaChans];248 const hidden = (state.settings.hidden || []).map((k) => known.find((c) => c.key === k) || unlistedChannel(k));249 if (hidden.length > 1) add({ id: 'all', kind: 'action', verb: 'shows them', label: 'All of them', value: 'Show', run: () => showHidden(hidden.map((c) => c.key)) });250 for (const c of hidden) {251 add({ id: c.key, kind: 'action', verb: 'shows it', label: `${c.number} ${c.network || displayCall(c)}`.trim(), value: 'Show', run: () => showHidden([c.key]) });252 }253 if (!hidden.length) add({ id: 'none', kind: 'info', label: 'No hidden channels', sub: HIDE_HOW() });254 }255 if (name === 'controls') {256 for (const [keys, pad, label, what] of CONTROLS) {257 add({ id: label, kind: 'info', label: what, html: `<span class="s-glyphs" title="${esc(padNames(pad))}">${keys.split(' ').map((k) => glyph(k === 'Space' ? ' ' : k, pad)).join('')}</span><kbd>${esc(label)}</kbd>` });258 }259 }260 if (name === 'podfound') podFoundRows(add);261 if (name === 'sources') {262 for (const x of (state.report && state.report.sources) || []) {263 add({ id: x.url, kind: 'action', verb: 'opens it', label: x.name, sub: x.use, value: 'Open', run: () => api().OpenURL(x.url) });264 }265 }266 return rows;267}268269function renderSettings() {270 if (!state.settings) return;271 const v = $('#settings');272 const list = f(v, 'list');273 // Not under the cursor while something is typed.274 if (list.contains(document.activeElement) && document.activeElement.matches('input')) return;275 if (state.view !== 'settings') settingsUI.sub = null;276 renderSettingsTabs();277 const label = (SETTINGS_TABS.find(([k]) => k === settingsUI.tab) || [])[1];278 const rows = settingsUI.rows = settingsUI.sub ? settingsList(settingsUI.sub) : settingsRows().filter((r) => r.section === label);279 settingsUI.at = Math.min(settingsUI.at, Math.max(0, rows.length - 1));280 let html = '';281 if (rows.length) html += `<h2 class="s-head">${esc(rows[0].section)}</h2><div class="s-group">${rows.map(settingsRowHTML).join('')}</div>`;282 if (settingsUI.sub === 'controls' && platform === 'tv') {283 html += '<p class="s-note">On the remote: OK brings up the controls over the picture, with Guide first, so OK twice opens the guide. In them Left and Right move along a row, Up and Down between rows: the timeline (Left and Right skip), the buttons (play, captions, record, last channel and more), and the views at the top. On live TV Up and Down change channel, and Left and Right skip. In the guide OK watches what\'s on, and holding OK offers more: record, series, favorite, hide. Back goes back. Remotes with them: digits tune a channel, Channel up and down change channel, Play/Pause pauses.</p>';284 } else if (settingsUI.sub === 'controls') {285 html += '<p class="s-note">Enter on TV brings up the controls, with the guide, captions, recording, the last channel and the views; Esc puts them away. Digits tune a channel. The remote\'s Fast forward and Rewind skip, Record records. Keyboard only: L last channel, C captions, V audio track, Shift F favorite, R record, D recordings, A reception, F full screen. In the guide hold Enter, or press I, for a program\'s actions. In Steam, give Airwaves the Gamepad controller layout, not a keyboard one.</p>';286 }287 const keep = list.scrollTop;288 list.innerHTML = html;289 list.scrollTop = keep;290 renderSettingsHint();291}292293function settingsRowHTML(r, i) {294 let value = '';295 let sub = r.sub || '';296 if (r.kind === 'choice') {297 const x = settingsValue(r);298 const at = r.choices.findIndex(([k]) => k === x);299 if (r.subFor) sub = r.subFor(x);300 value = `<i class="s-step${at <= 0 ? ' end' : ''}" data-step="-1">‹</i><span class="s-choice">${esc(at >= 0 ? r.choices[at][1] : x)}</span><i class="s-step${at >= r.choices.length - 1 ? ' end' : ''}" data-step="1">›</i>`;301 } else if (r.kind === 'switch') {302 const on = settingsValue(r);303 value = `<span>${on ? 'On' : 'Off'}</span><span class="s-switch${on ? ' on' : ''}"><i></i></span>`;304 } else if (r.kind === 'text') {305 value = `<input name="${r.name}" type="${r.password ? 'password' : 'text'}" value="${esc(r.text)}" data-was="${esc(r.text)}" placeholder="${esc(r.placeholder || '')}" autocapitalize="off" autocomplete="off" spellcheck="false">`;306 } else {307 value = `${r.html || `<span>${esc(r.value || '')}</span>`}${r.kind === 'open' ? '<i class="s-more">›</i>' : ''}`;308 }309 return `<div class="s-row k-${r.kind}${i === settingsUI.at ? ' focus' : ''}" data-i="${i}" role="listitem">310 <div class="s-label"><b>${esc(r.label)}</b>${sub ? `<small>${esc(sub)}</small>` : ''}</div><div class="s-value">${value}</div></div>`;311}312313// renderSettingsTabs lists the tabs in the rail, the open one marked.314function renderSettingsTabs() {315 const box = f($('#settings'), 'tabs');316 const html = settingsTabs().map(([k, label]) => `<button type="button" class="rm-item fx ${k === settingsUI.tab ? 'on' : ''}" data-tab="${k}" role="tab" aria-selected="${k === settingsUI.tab}">317 <span class="rm-label">${esc(label)}</span></button>`).join('');318 if (box.innerHTML === html) return;319 const had = box.contains(document.activeElement);320 box.innerHTML = html;321 if (had) focusEl($(`[data-tab="${settingsUI.tab}"]`, box), false);322}323324function renderSettingsHint() {325 const hint = f($('#settings'), 'hint');326 if (settingsUI.zone === 'tabs') {327 hint.innerHTML = hints([['UpDown', 'Up, Down', 'picks'], ['Enter', 'Enter', 'goes in'], ['Escape', 'Esc', 'leaves']]);328 return;329 }330 if (settingsUI.tab === 'reception') {331 hint.innerHTML = hints([['Arrows', 'Arrows', 'move'], ['Enter', 'Enter', 'measures, or watches the RF channel picked'], ['Escape', 'Esc', 'back to the tabs']]);332 return;333 }334 const r = settingsUI.rows[settingsUI.at];335 const top = settingsUI.at === 0 && !settingsUI.sub;336 const items = [['UpDown', 'Up, Down', top ? 'move, Up to the menu' : 'move']];337 if (r && (r.kind === 'choice' || r.kind === 'switch')) items.push(['LeftRight', 'Left, Right', 'change']);338 if (r && r.kind === 'switch') items.push(['Enter', 'Enter', 'switches']);339 if (r && r.kind === 'open') items.push(['Enter', 'Enter', 'opens']);340 if (r && r.kind === 'action') items.push(['Enter', 'Enter', r.verb || 'runs']);341 if (r && r.kind === 'text') items.push(['Enter', 'Enter', androidTV ? 'types in it' : 'types in it, with a keyboard']);342 items.push(['Escape', 'Esc', settingsUI.sub ? 'back' : 'back to the tabs']);343 hint.innerHTML = hints(items);344}345346function moveSetting(i, scroll = true) {347 const rows = settingsUI.rows;348 if (!rows.length) return;349 settingsUI.at = Math.max(0, Math.min(rows.length - 1, i));350 const list = f($('#settings'), 'list');351 for (const el of list.querySelectorAll('.s-row.focus')) el.classList.remove('focus');352 const el = list.querySelector(`.s-row[data-i="${settingsUI.at}"]`);353 if (el) {354 el.classList.add('focus');355 if (scroll && settingsUI.at === 0) list.scrollTop = 0;356 else if (scroll) el.scrollIntoView({ block: 'nearest' });357 }358 renderSettingsHint();359}360361// settingsKey handles a key in Settings, or returns false to leave it to362// the keys every view has (Esc goes back to TV).363function settingsKey(e) {364 if (!settingsUI.sub && (e.key === 'PageUp' || e.key === 'PageDown' || e.key === '[' || e.key === ']')) {365 e.preventDefault();366 stepSettingsTab(e.key === 'PageDown' || e.key === ']' ? 1 : -1);367 return true;368 }369 if (settingsUI.zone === 'tabs') return settingsTabsKey(e);370 if (settingsUI.tab === 'reception') return antennaKey(e);371 const r = settingsUI.rows[settingsUI.at];372 switch (e.key) {373 case 'ArrowUp':374 if (settingsUI.at > 0) moveSetting(settingsUI.at - 1);375 else if (!settingsUI.sub) openDock();376 break;377 case 'ArrowDown': moveSetting(settingsUI.at + 1); break;378 case 'PageUp': case 'PageDown': moveSetting(settingsUI.at + (e.key === 'PageDown' ? 8 : -8)); break;379 case 'ArrowLeft':380 if (r && r.kind === 'choice') stepSetting(r, -1);381 else if (r && r.kind === 'switch') { if (settingsValue(r)) changeSetting(r, false); }382 else if (settingsUI.sub) closeSettingsList();383 else settingsZone('tabs');384 break;385 case 'ArrowRight':386 if (r && r.kind === 'choice') stepSetting(r, 1);387 else if (r && r.kind === 'switch') { if (!settingsValue(r)) changeSetting(r, true); }388 else if (r && r.kind === 'open') activateSetting(r);389 break;390 case 'Enter':391 if (r && r.kind === 'choice') stepSetting(r, 1, true);392 else if (r) activateSetting(r);393 break;394 case 'Escape':395 if (!settingsUI.sub) return false;396 closeSettingsList();397 break;398 default: return false;399 }400 e.preventDefault();401 return true;402}403404// settingsTabsKey: Up and Down pick a tab (Up from the first to the dock),405// Right or OK goes into it, Left to the dock.406function settingsTabsKey(e) {407 const tabs = settingsTabs();408 const i = tabs.findIndex(([k]) => k === settingsUI.tab);409 switch (e.key) {410 case 'ArrowUp': if (i > 0) stepSettingsTab(-1); else openDock(); break;411 case 'ArrowDown': stepSettingsTab(1); break;412 case 'ArrowRight': case 'Enter': settingsZone('list'); break;413 case 'ArrowLeft': openDock(); break;414 default: return false;415 }416 e.preventDefault();417 return true;418}419420function stepSetting(r, dir, wrap = false) {421 const x = settingsValue(r);422 const n = r.choices.length;423 let i = r.choices.findIndex(([k]) => k === x);424 i = wrap ? (i + dir + n) % n : Math.max(0, Math.min(n - 1, i + dir));425 if (r.choices[i][0] !== x) changeSetting(r, r.choices[i][0]);426}427428// changeSetting shows the new value at once and saves it; a row with delay429// (a rescan or a call to the server) saves once the value rests.430function changeSetting(r, value) {431 settingsUI.pending[r.id] = value;432 renderSettings();433 clearTimeout(settingsUI.timers[r.id]);434 const save = async () => {435 try {436 await r.set(value);437 } catch (err) {438 toast(String(err && err.message ? err.message : err), 6000);439 }440 if (settingsUI.pending[r.id] === value) delete settingsUI.pending[r.id];441 renderSettings();442 };443 if (r.delay) settingsUI.timers[r.id] = setTimeout(save, 900);444 else save();445}446447function activateSetting(r) {448 if (r.kind === 'switch') return changeSetting(r, !settingsValue(r));449 if (r.kind === 'open') return openSettingsList(r.open);450 if (r.kind === 'text') {451 const input = f($('#settings'), 'list').querySelector(`input[name="${r.name}"]`);452 if (input) { input.focus(); input.select(); }453 return null;454 }455 if (r.run) return Promise.resolve(r.run()).catch((err) => toast(String(err && err.message ? err.message : err), 6000));456 return null;457}458459function openSettingsList(name) {460 settingsUI.from = settingsUI.at;461 settingsUI.sub = name;462 settingsUI.at = 0;463 renderSettings();464 f($('#settings'), 'list').scrollTop = 0;465}466467function closeSettingsList() {468 settingsUI.sub = null;469 settingsUI.at = settingsUI.from;470 renderSettings();471 moveSetting(settingsUI.at);472}473474// ---------- what the settings rows do ----------475476async function setGuideHours(hours) {477 toast('Updating the listings...', 30000);478 state.config = await api().SetConfig({ ...state.config, guideHours: hours });479 await scan(false);480 renderAll();481 toast(`${hours} hours of listings`, 2000);482}483484async function setCaptionsDefault(on) {485 await saveAppSettings({ ...state.settings, captions: on });486 applyCaptions();487 toast(on ? 'Captions on' : 'Captions off', 1800);488}489490async function setAudioLang(lang) {491 await saveAppSettings({ ...state.settings, audioLang: lang });492 applyAudio();493 toast(`Audio: ${(AUDIO_LANGS.find(([k]) => k === lang) || [lang, lang.toUpperCase()])[1]}`, 1800);494}495496async function setWatched(days) {497 const prefs = await api().SetDVRPrefs({ deleteWatchedAfterDays: days });498 if (state.dvr) state.dvr.prefs = prefs;499 toast(prefs.deleteWatchedAfterDays ? `Watched recordings are deleted after ${prefs.deleteWatchedAfterDays} day${prefs.deleteWatchedAfterDays > 1 ? 's' : ''}` : 'Watched recordings are kept', 2500);500}501502async function hideShopping() {503 if (!state.report) return;504 // By the listings' network, or the broadcast's own name ("ShopLC").505 const shop = state.antennaChans.filter((c) => SHOPPING.test(c.network || '') || SHOPPING.test(c.name || '')).map((c) => c.key);506 const was = new Set(state.settings.hidden || []);507 const added = shop.filter((k) => !was.has(k)).length;508 if (!added) return toast(shop.length ? 'Shopping channels are hidden already' : 'No shopping channels to hide', 2500);509 await saveAppSettings({ ...state.settings, hidden: [...was, ...shop.filter((k) => !was.has(k))] });510 buildLineup();511 renderAll();512 return toast(`Hid ${added} shopping channel${added > 1 ? 's' : ''}`);513}514515async function showHidden(keys) {516 await unhide(keys);517 toast(keys.length > 1 ? 'Every channel shown again' : 'Channel shown again', 1800);518 if (!(state.settings.hidden || []).length) closeSettingsList();519}520521async function testServer() {522 const s = state.settings;523 settingsUI.tested = `Connecting to ${s.server}...`;524 renderSettings();525 try {526 const info = await api().TestServer(s.server, s.token || '');527 settingsUI.tested = `Connected to ${info.name}`;528 } catch (err) {529 settingsUI.tested = `Can't reach ${s.server}`;530 toast(`Can't reach ${s.server}: ${err && err.message ? err.message : err}`, 6000);531 }532 renderSettings();533}534535async function refreshAll() {536 toast('Fetching everything again...', 60000);537 await scan(true);538 renderAll();539 toast(`Updated. ${state.lineup.length} channels.`, 2500);540}541542// saveServer switches to a new server address or token, after checking it543// answers; the app starts over with it.544async function saveServer(name, value) {545 const prev = state.settings;546 const next = { ...prev, [name]: name === 'server' ? value.trim() : value };547 if (next.server === prev.server && next.token === prev.token) return;548 if (!next.server) {549 toast('Enter the server address', 3000);550 return renderSettings();551 }552 toast(`Connecting to ${next.server}...`, 15000);553 const boot = await api().SaveSettings(next);554 if (boot.error) {555 applyBoot(await api().SaveSettings(prev));556 toast(`Can't reach ${next.server}: ${boot.error}`, 6000);557 return renderSettings();558 }559 location.reload();560 return null;561}562563// ---------- interface size ----------564// Larger for a TV across the room. On Linux WebKitGTK zooms the page (Go565// sets it at launch, SetZoom after); elsewhere CSS zoom on the root scales566// everything together, and style.css undoes it for viewport units. Auto567// is 125% under gamescope, where the window is 1080p on a TV, else 100%.568// On the Android TV app sizes are a 1080p screen's, whatever the WebView's569// (fit, from web.js).570const SCALES = [0, 100, 115, 130, 150]; // 0 is Auto571// Auto is the app's for the platform it says it is; for another, chosen in572// Settings, a desktop's 100% or 125% across a room.573const autoScale = () => (platform === detectedPlatform ? (state.boot && state.boot.autoScale) || 100 : platform === 'desktop' || platform === 'touch' ? 100 : 125);574let pageZoom = 0;575576function applyScale() {577 const fit = (state.boot && state.boot.fit) || 1;578 const z = (((state.settings && state.settings.scale) || autoScale()) / 100) * fit;579 const root = document.documentElement;580 const native = !!(state.boot && state.boot.nativeZoom);581 if (native && z !== pageZoom) {582 pageZoom = z;583 api().SetZoom(z);584 } else if (!native) {585 root.style.zoom = z === 1 ? '' : String(z);586 root.style.setProperty('--zoom', String(z));587 }588 // The viewport in the root's own pixels, for style.css's --vw and --vh:589 // engines differ on what vw and vh mean under CSS zoom (the Mac app's590 // WebKit sizes the guide's floating TV and its grid differently), while591 // pixels zoom alike everywhere.592 const cssZoom = native ? 1 : z;593 root.style.setProperty('--vw', `${innerWidth / cssZoom / 100}px`);594 root.style.setProperty('--vh', `${innerHeight / cssZoom / 100}px`);595 // Larger sizes leave less room above the guide (.tight in style.css):596 // the size chosen, not the zoom, which on the Android TV app also fits a597 // 1080p screen's interface into the WebView.598 root.classList.toggle('tight', z / fit > 1 && (native ? innerHeight : innerHeight / z) < 900);599 fitWX();600 if (state.view === 'guide' && state.report) renderGuide();601}602603const PLATFORM_SUBS = {604 desktop: 'For a mouse and a keyboard',605 console: 'For a controller, across a room',606 tv: 'For a TV remote: arrows, OK and Back',607 touch: 'For a phone or a tablet: tap, swipe and hold',608};609610async function setPlatform(k) {611 if (k) localStorage.setItem(platformKey, k); else localStorage.removeItem(platformKey);612 if (applyPlatform()) {613 applyScale();614 renderHints();615 }616 toast(`Layout: ${k ? PLATFORMS.find(([x]) => x === k)[1] : `automatic, ${PLATFORMS.find(([x]) => x === detectedPlatform)[1]}`}`, 1800);617}618619function setPadLabels(k) {620 if (k) localStorage.setItem(padLabelsKey, k); else localStorage.removeItem(padLabelsKey);621 applyPadFamily();622 toast(`Button labels: ${PAD_LABELS.find(([x]) => x === k)[1]}`, 1800);623}624625async function setScale(s) {626 await saveAppSettings({ ...state.settings, scale: s });627 toast(s ? `Interface size ${s}%` : `Interface size automatic, ${autoScale()}%`, 1800);628}629630// unlistedChannel stands for a hidden channel that isn't listed now (an631// antenna channel out of range, or one of the server's turned off), by its632// key: "7.1|KMGH", "104.0|custom|Cartoons" or the weather channel's.633function unlistedChannel(k) {634 if (k === WEATHER_KEY) return { key: k, number: '', network: 'Weather channel' };635 const [number, call, ...rest] = String(k).split('|');636 return call === 'custom' ? { key: k, number, network: rest.join('|') || 'Custom channel' } : { key: k, number, callSign: call };637}638639window.addEventListener('resize', applyScale);640641// ---------- favorites and hidden channels ----------642async function saveAppSettings(next) {643 applyBoot(await api().SaveSettings(next));644}645646async function toggleFavorite(ch) {647 if (!ch) return;648 const favs = new Set(state.settings.favorites || []);649 const on = !favs.has(ch.key);650 if (on) favs.add(ch.key); else favs.delete(ch.key);651 await saveAppSettings({ ...state.settings, favorites: [...favs] });652 toast(on ? `${ch.number} ${ch.network || displayCall(ch)} added to favorites` : `${ch.number} removed from favorites`);653 if (state.view === 'guide') renderGuide();654}655656async function hideChannel(ch) {657 if (!ch) return;658 const hidden = new Set(state.settings.hidden || []);659 hidden.add(ch.key);660 state.lastHidden = ch.key;661 await saveAppSettings({ ...state.settings, hidden: [...hidden] });662 buildLineup();663 toast(`Hid ${ch.number} ${ch.network || displayCall(ch)}. ${onKeys() ? 'Press U to undo.' : 'Settings, Hidden channels shows it again.'}`, 5000);664 if (state.view === 'guide') renderGuide();665}666667async function unhide(keys) {668 const hidden = new Set(state.settings.hidden || []);669 for (const k of keys) hidden.delete(k);670 await saveAppSettings({ ...state.settings, hidden: [...hidden] });671 buildLineup();672 renderAll();673}674675const SHOPPING = /\b(qvc2?|hsn2?|shop ?lc|jewelry|jtv|tvdeals|deals|shopping)\b/i;