clone

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

main
airwaves / frontend / dist / js / input.js
input.js 24.4K
1'use strict';23// Keys, a remote, controllers, the dock, focus and the hints that name them.45// ---------- keyboard ----------6// Text fields keep their keys (but Esc).7const TEXT_INPUT = 'input:not([type=checkbox]):not([type=radio]), textarea, select';89// 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.12const 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};2021// 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).24function pressKey(key, { shiftKey = false, repeat = false } = {}) {25 (document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', { key, shiftKey, repeat, bubbles: true, cancelable: true }));26}2728// 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.30const held = new Map(); // key: when its last keydown came31const HELD_MS = 700;3233function 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' && state.view !== 'podcasts') return setView(state.view === 'weather' ? 'tv' : 'weather');83 if ((k === 'p' || k === 'P') && state.info.podcasts) return setView(state.view === 'podcasts' ? 'tv' : 'podcasts');84 if (k === ',' || k === 's' || k === 'S') return setView(state.view === 'settings' ? 'tv' : 'settings');85 if (k === 'm' || k === 'M') {86 const onWX = state.current && state.current.weather;87 const muted = !(onWX ? state.userMuted : video.muted);88 state.userMuted = muted;89 if (!state.wxPreview) video.muted = muted;90 wxMusic.muted = muted;91 return toast(muted ? 'Muted' : 'Sound on', 1200);92 }93 if ((k === 'u' || k === 'U') && state.lastHidden) {94 const key = state.lastHidden;95 state.lastHidden = null;96 return unhide([key]).then(() => toast('Channel shown again', 1800));97 }98 if (k === 'f') return toggleFullscreen();99100 if (state.view === 'guide') return guideKey(e);101 if (state.view === 'recordings') return recordingsKey(e);102 if (state.view === 'podcasts') return podcastsKey(e);103 if (state.view === 'weather') return weatherKey(e);104 if (state.view !== 'tv') return;105106 if (/^[0-9]$/.test(k) || (k === '.' && state.entry)) { e.preventDefault(); return entryKey(k); }107 switch (k) {108 case 'ArrowUp': case 'PageUp': e.preventDefault(); return step(-1);109 case 'ArrowDown': case 'PageDown': e.preventDefault(); return step(1);110 // Left and Right skip, and bring up the controls with the timeline111 // picked, for more of the same.112 case 'ArrowLeft': e.preventDefault(); skip(e.shiftKey ? -60 : -10); return openOSD('timeline');113 case 'ArrowRight': e.preventDefault(); skip(e.shiftKey ? 60 : 30); return openOSD('timeline');114 case ' ': e.preventDefault(); return togglePause();115 case 'End': e.preventDefault(); return goLive();116 case 'Enter': e.preventDefault(); return state.entry ? commitEntry() : openOSD();117 case 'i': case 'I': return cycleBanner();118 case 'l': case 'L': case 'Backspace': return state.previous && tune(state.previous);119 case 'r': return recordNow('once');120 case 'R': return recordNow('series', false);121 case 'n': case 'N': return recordNow('series', true);122 case 'c': case 'C': return toggleCaptions();123 case 'v': case 'V': return cycleAudio();124 case 'F': return toggleFavorite(state.current);125 default:126 }127 });128 document.addEventListener('keyup', (e) => {129 held.delete(e.key);130 holdEnd(e);131 });132 window.addEventListener('blur', () => held.clear());133}134135// ---------- holding OK ----------136// Holding OK (or Enter) for more, as on Android TV and in Kodi, where a long137// press brings up a context menu: in the guide, a program's actions. A138// press acts on its keyup; a hold once the key comes again (Android sends139// a held key again at its long-press timeout, a keyboard repeats) or it's140// been down HOLD_MS. A controller's button (pressKey) has no keyup, and141// acts at once.142const HOLD_MS = 500;143const hold = { timer: 0, short: null, long: null };144145function holdStart(short, long) {146 clearTimeout(hold.timer);147 hold.short = short;148 hold.long = long;149 hold.timer = setTimeout(holdLong, HOLD_MS);150}151152function holdLong() {153 if (!hold.timer) return;154 clearTimeout(hold.timer);155 hold.timer = 0;156 const long = hold.long;157 hold.short = null;158 hold.long = null;159 if (long) long();160}161162function holdEnd(e) {163 if (e.key !== 'Enter' || !hold.timer) return;164 clearTimeout(hold.timer);165 hold.timer = 0;166 const short = hold.short;167 hold.short = null;168 hold.long = null;169 if (short) short();170}171172// bootKey is a key while starting: on the connect prompt the arrows move173// between its fields and Connect (Up and Down only, while typing), and OK174// on a field puts the cursor in it.175function bootKey(e, typing) {176 const form = $('.boot-connect');177 if (form.hidden) {178 if (!typing && (e.key === 'r' || e.key === 'R')) location.reload();179 return;180 }181 const items = $$('.fx', form);182 const el = document.activeElement;183 const i = items.indexOf(el && el.closest ? el.closest('.fx') : null);184 if (e.key === 'Enter' && el && el.matches('.boot-field')) {185 e.preventDefault();186 const input = $('input', el);187 input.focus();188 input.select();189 return;190 }191 const dir = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[e.key];192 if (!dir || (typing && (e.key === 'ArrowLeft' || e.key === 'ArrowRight'))) return;193 e.preventDefault();194 bootField(items[i < 0 ? 0 : Math.max(0, Math.min(items.length - 1, i + dir))]);195}196197// bootField moves to a field of the connect prompt: on a TV to its row, as198// a field with the cursor in it would bring up the keyboard at the next199// key; elsewhere into it, to type.200function bootField(el) {201 if (!el) return;202 const input = el.matches('.boot-field') && !androidTV ? $('input', el) : null;203 if (input) { input.focus(); input.select(); } else focusEl(el);204}205206// On the Android TV app the keyboard comes up over the page (the visual207// viewport shrinks); put away with Back, it leaves its field with the208// cursor, where the next arrow would bring it up again. So the field lets209// go then, back to its row, as Esc does: a setting's text goes back to210// what it was (Enter, the keyboard's own key, keeps it).211const ime = { open: false };212if (androidTV && window.visualViewport) {213 visualViewport.addEventListener('resize', () => {214 const el = document.activeElement;215 const typing = !!(el && el.matches && el.matches(TEXT_INPUT));216 if (visualViewport.height < innerHeight * 0.9) { ime.open = typing; return; }217 if (!ime.open) return;218 ime.open = false;219 if (!typing) return;220 if (el.dataset.was !== undefined) el.value = el.dataset.was;221 const row = el.closest('.fx');222 el.blur();223 if (row) focusEl(row); else restoreFocus();224 });225}226227// airwavesBack is the Android TV app's Back key: it does what Esc does and228// tells whether that went back from anything. False (live TV with nothing229// open, or still starting) lets the app close.230window.airwavesBack = () => {231 if (!state.settings || document.body.classList.contains('booting')) return false;232 const el = document.activeElement;233 const typing = !!(el && el.matches && el.matches(TEXT_INPUT));234 const open = state.dock >= 0 || typing || !!state.entry || state.view !== 'tv' || osd.open235 || !!(state.recording && state.current) || $('#banner').classList.contains('show');236 if (!open) return false;237 pressKey('Escape');238 return true;239};240241// In the background (the Home key) the Android TV app's live TV stops, so242// the server's tuner is free, and tunes again on return; a recording243// pauses where it is.244document.addEventListener('visibilitychange', () => {245 if (!androidTV || !state.settings || document.body.classList.contains('booting')) return;246 if (document.hidden && state.recording) {247 video.pause();248 } else if (document.hidden && state.current) {249 state.away = true;250 state.tuneToken++;251 resetPlayer();252 wxMusic.pause();253 api().StopTV();254 } else if (!document.hidden && state.away) {255 state.away = false;256 if (!state.recording) tune(state.current, { quiet: true });257 }258});259260// ---------- dock ----------261// Without letter keys (a TV remote, a controller) the dock leads to every262// view: Left from its left edge, or Up from the top of a view, moves to263// it, and on TV it's the row above the on-screen controls. Left and Right264// pick, Enter opens, Esc or Down goes back to where the focus was.265const dockButtons = () => $$('#dock button').filter((b) => b.offsetParent);266267function openDock() {268 const el = document.activeElement;269 if (el && el !== document.body && !el.matches(TEXT_INPUT)) el.blur();270 state.dock = Math.max(0, dockButtons().findIndex((b) => b.dataset.view === state.view));271 renderDock();272}273274function closeDock(restore = true) {275 state.dock = -1;276 renderDock();277 if (restore) restoreFocus();278}279280function renderDock() {281 document.body.classList.toggle('dock-nav', state.dock >= 0);282 dockButtons().forEach((b, i) => b.classList.toggle('pick', i === state.dock));283}284285// dockKey handles a key while the dock is picked; other keys leave it.286function dockKey(e) {287 const buttons = dockButtons();288 switch (e.key) {289 case 'ArrowLeft': state.dock = Math.max(0, state.dock - 1); break;290 case 'ArrowRight': state.dock = Math.min(buttons.length - 1, state.dock + 1); break;291 case 'ArrowUp': break;292 case 'Enter': case ' ': {293 const b = buttons[state.dock];294 closeDock(false);295 if (b) setView(b.dataset.view);296 e.preventDefault();297 return true;298 }299 case 'ArrowDown':300 // In the guide the filters sit just below the dock.301 if (state.view === 'guide') { closeDock(false); guideZone('filters'); } else closeDock();302 e.preventDefault();303 return true;304 case 'Escape': closeDock(); e.preventDefault(); return true;305 default: closeDock(); return false;306 }307 e.preventDefault();308 renderDock();309 return true;310}311312// ---------- focus ----------313// Where the remote, a controller or the arrow keys are is always marked,314// the same way in every view (style.css, Focus). Buttons, fields and the315// weather's parts take the page's focus (.fx); the guide's grid and the316// lists of Recordings, Reception and Settings mark their pick with a class,317// while that part of the view has the focus (its data-zone). Only keys318// focus: a click leaves the focus where it was (wireMouse).319const isShown = (el) => !!el && !el.hidden && el.getClientRects().length > 0;320321function focusEl(el, scroll = true) {322 if (!el) return false;323 if (document.activeElement !== el) el.focus({ preventScroll: true });324 if (scroll && el.closest('.wx-main, .r-grid, .r-list, .s-list, .a-tablewrap')) el.scrollIntoView({ block: 'nearest' });325 return document.activeElement === el;326}327328// blurIn takes the focus off whatever has it inside root.329function blurIn(root) {330 const el = document.activeElement;331 if (el && el !== document.body && root.contains(el)) el.blur();332}333334// neighbor is the item dir (-1 or 1) from el in items, or null past either335// end: rows of buttons stop at their ends.336function neighbor(items, el, dir) {337 const i = items.indexOf(el);338 return i < 0 ? items[0] || null : items[i + dir] || null;339}340341// restoreFocus puts the focus back where it was in the view, after the dock342// or a field let it go.343function restoreFocus() {344 if (state.dock >= 0) return;345 if (state.view === 'tv' && osd.open) osdFocus(osd.last);346 else if (state.view === 'guide') guideZone(guideUI.zone);347 else if (state.view === 'recordings') recZone(recUI.zone);348 else if (state.view === 'podcasts') podZone(podUI.zone);349 else if (state.view === 'settings') settingsRefocus();350 else if (state.view === 'weather') wxFocus();351}352353// viewBack is Back in an inner part of a view (a program's or a354// recording's actions, the guide's filters): it goes to the view's main355// part, and reports whether it did.356function viewBack() {357 if (state.view === 'guide' && state.recPick) { closeRecordPick(); return true; }358 if (state.view === 'guide' && guideUI.zone !== 'grid') { guideZone('grid'); return true; }359 if (state.view === 'recordings' && recUI.zone === 'actions') { recZone('list'); return true; }360 if (state.view === 'podcasts' && podUI.zone === 'actions') { podZone('grid'); return true; }361 // In a tab of Settings, Back goes to the tabs, as on Android TV.362 if (state.view === 'settings' && settingsUI.zone === 'list') { settingsZone('tabs'); return true; }363 return false;364}365366// ---------- game controllers ----------367// A game controller does what the keyboard does: each button stands for a368// key and goes through the same handler. Indexes are the standard gamepad369// mapping (A, B, X, Y, LB, RB, LT, RT, Back, Start, L3, R3, d-pad); Guide is370// Steam's. WebKit lists a controller once a button is pressed; while one is371// connected the pads are read every frame, and not while the window is372// hidden. Pads are merged, so one controller seen twice presses once.373//374// The pads are read raw, so Steam's own menus don't hide them from the app.375// Guide opens Steam's menu, and Guide with A its quick access menu, which376// leave the window focused under gamescope: from Guide until Guide again or377// B (which closes them), the pads are Steam's. Nothing acts while the window378// is unfocused either, and a button held when the app gets the pads back379// acts only once let go and pressed again.380const PAD_KEYS = [381 ['Enter'], ['Escape'], ['i'], ['g'],382 ['PageUp'], ['PageDown'], ['ArrowLeft', true], ['ArrowRight', true],383 ['w'], [','], ['m'], [' '],384 ['ArrowUp'], ['ArrowDown'], ['ArrowLeft'], ['ArrowRight'],385];386const PAD_ARROWS = 12; // the d-pad (and the left stick) repeat when held387// A direction held longer than this stops repeating until it's let go: a388// pad whose state froze mid-press (asleep, or taken by Steam) would389// otherwise move the guide or the dock on its own.390const PAD_REPEAT_MAX = 4000;391const pad = { raf: 0, held: [], stick: -1, greeted: false, guide: false, away: false, mute: false };392const PAD_GUIDE = 16;393const PAD_B = 1;394395function wirePads() {396 if (!navigator.getGamepads) return;397 window.addEventListener('gamepadconnected', (e) => {398 log('info', `controller connected: ${e.gamepad.id} (${e.gamepad.mapping || 'no'} mapping)`);399 setPadFamily(e.gamepad.id);400 if (!pad.greeted) {401 pad.greeted = true;402 toast('Controller connected', 2500);403 }404 padWatch();405 });406 window.addEventListener('gamepaddisconnected', (e) => log('info', `controller disconnected: ${e.gamepad.id}`));407 document.addEventListener('visibilitychange', () => {408 if (!document.hidden) {409 pad.mute = true;410 return padWatch();411 }412 cancelAnimationFrame(pad.raf);413 pad.raf = 0;414 });415 window.addEventListener('focus', () => {416 pad.away = false;417 pad.mute = true;418 });419 padWatch(); // pads already listed, after a reload420}421422// padWatch reads the pads on the next frame, unless the window is hidden.423function padWatch() {424 if (!pad.raf && !document.hidden) pad.raf = requestAnimationFrame(padPoll);425}426427function padPoll(now) {428 pad.raf = 0;429 const pads = [...navigator.getGamepads()].filter(Boolean);430 if (!pads.length) {431 pad.held = [];432 pad.stick = -1;433 return;434 }435 const down = padButtons(pads);436 const guide = pads.some((p) => p.buttons[PAD_GUIDE] && p.buttons[PAD_GUIDE].pressed);437 if (guide && !pad.guide) pad.away = !pad.away;438 else if (pad.away && pad.held[PAD_B] && !down[PAD_B]) pad.away = false;439 pad.guide = guide;440 const live = !pad.away && !pad.mute && document.hasFocus();441 pad.mute = false;442 down.forEach((on, i) => {443 const h = pad.held[i];444 if (!on) {445 pad.held[i] = null;446 } else if (!live) {447 pad.held[i] = h || { next: Infinity }; // acts once let go and pressed again448 } else if (!h) {449 pad.held[i] = { next: now + 400, since: now };450 padPress(i, false);451 } else if (i >= PAD_ARROWS && now >= h.next && now - h.since < PAD_REPEAT_MAX) {452 h.next = now + 120;453 padPress(i, true);454 }455 });456 padWatch();457}458459// padButtons merges the pads' buttons, with the left stick as the d-pad: it460// points along its stronger axis once pushed past 0.6, until back under 0.35.461function padButtons(pads) {462 const down = PAD_KEYS.map(() => false);463 let x = 0;464 let y = 0;465 for (const p of pads) {466 for (let i = 0; i < down.length && i < p.buttons.length; i++) {467 if (p.buttons[i].pressed || p.buttons[i].value > 0.5) down[i] = true;468 }469 if (Math.abs(p.axes[0] || 0) > Math.abs(x)) x = p.axes[0];470 if (Math.abs(p.axes[1] || 0) > Math.abs(y)) y = p.axes[1];471 }472 const along = { 12: -y, 13: y, 14: -x, 15: x };473 if (pad.stick >= 0 && along[pad.stick] < 0.35) pad.stick = -1;474 if (pad.stick < 0 && Math.max(Math.abs(x), Math.abs(y)) > 0.6) {475 pad.stick = Math.abs(x) > Math.abs(y) ? (x < 0 ? 14 : 15) : (y < 0 ? 12 : 13);476 }477 if (pad.stick >= 0) down[pad.stick] = true;478 return down;479}480481function padPress(i, repeat) {482 setPrompts(true);483 // On the TV, A plays and pauses: the guide has its own button, and the484 // remote's OK (Enter) brings up the controls. With them up, A presses.485 if (i === 0 && state.view === 'tv' && !state.entry && !osd.open && state.dock < 0) return pressKey(' ', { repeat });486 const [key, shiftKey] = PAD_KEYS[i];487 pressKey(key, { shiftKey: !!shiftKey, repeat });488}489490// ---------- key and button hints ----------491// Hints name keys, or a controller's buttons once one was used (Kenney's492// CC0 Input Prompts, in vendor/kenney-prompts), until a key is pressed493// again. Glyphs by the key a button stands for (PAD_KEYS); the face buttons494// are where the standard mapping puts them, so a Switch pad's read B A Y X.495const GLYPHS = {496 xbox: ['xbox_button_a', 'xbox_button_b', 'xbox_button_x', 'xbox_button_y', 'xbox_lb', 'xbox_rb', 'xbox_lt', 'xbox_rt',497 'xbox_button_view', 'xbox_button_menu', 'xbox_ls', 'xbox_rs', 'xbox_dpad_none', 'xbox_dpad_vertical', 'xbox_dpad_horizontal'],498 playstation: ['playstation_button_cross', 'playstation_button_circle', 'playstation_button_square', 'playstation_button_triangle',499 'playstation_trigger_l1', 'playstation_trigger_r1', 'playstation_trigger_l2', 'playstation_trigger_r2', 'playstation5_button_create',500 'playstation5_button_options', 'playstation_button_l3', 'playstation_button_r3', 'playstation_dpad_none', 'playstation_dpad_vertical', 'playstation_dpad_horizontal'],501 switch: ['switch_button_b', 'switch_button_a', 'switch_button_y', 'switch_button_x', 'switch_button_l', 'switch_button_r', 'switch_button_zl',502 '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'],503 steamdeck: ['steamdeck_button_a', 'steamdeck_button_b', 'steamdeck_button_x', 'steamdeck_button_y', 'steamdeck_button_l1', 'steamdeck_button_r1',504 'steamdeck_button_l2', 'steamdeck_button_r2', 'steamdeck_button_view', 'steamdeck_button_options', 'steamdeck_stick_l_press', 'steamdeck_stick_r_press',505 'steamdeck_dpad_none', 'steamdeck_dpad_vertical', 'steamdeck_dpad_horizontal'],506};507// The key each glyph above stands for, in order.508const GLYPH_KEYS = ['Enter', 'Escape', 'i', 'g', 'PageUp', 'PageDown', 'ShiftLeft', 'ShiftRight', 'w', ',', 'm', ' ', 'Arrows', 'UpDown', 'LeftRight'];509const prompts = { pad: false, family: 'xbox', detected: 'xbox' };510511// An 8BitDo pad's X and Y arrive swapped (its X, on the left, reads as the512// standard mapping's top button), so it gets Xbox glyphs with X and Y513// traded.514GLYPHS.xboxswap = GLYPHS.xbox.map((g, i) => GLYPHS.xbox[i === 2 ? 3 : i === 3 ? 2 : i]);515516// PAD_LABELS name the glyph sets, for the Settings choice that overrides517// what the pad's id suggests.518const PAD_LABELS = [['', 'Auto'], ['xbox', 'Xbox'], ['xboxswap', 'Xbox, X and Y swapped'], ['switch', 'Nintendo'], ['playstation', 'PlayStation'], ['steamdeck', 'Steam Deck']];519const padLabelsKey = 'airwaves.padLabels';520521function setPadFamily(id) {522 prompts.detected = /054c|sony|playstation|dualsense|dualshock/i.test(id) ? 'playstation'523 : /057e|nintendo|switch/i.test(id) ? 'switch'524 : /2dc8|8bitdo/i.test(id) ? 'xboxswap'525 : /28de|valve|steam deck/i.test(id) ? 'steamdeck' : 'xbox';526 applyPadFamily();527}528529function applyPadFamily() {530 const forced = localStorage.getItem(padLabelsKey) || '';531 const family = GLYPHS[forced] ? forced : prompts.detected;532 if (family === prompts.family) return;533 prompts.family = family;534 renderHints();535}536537function setPrompts(pad) {538 if (pad === prompts.pad) return;539 prompts.pad = pad;540 document.body.classList.toggle('pad-input', pad);541 renderHints();542}543544// glyph is the controller button for key, or '' when there is none.545function glyph(key, label) {546 const i = GLYPH_KEYS.indexOf(key);547 if (i < 0) return '';548 const file = GLYPHS[prompts.family][i];549 return `<img class="glyph" src="vendor/kenney-prompts/${file}${/press|dpad_none/.test(file) ? '' : '_outline'}.svg" alt="${esc(label)}">`;550}551552// The keys of the Android TV app's remote, as hints name them.553const REMOTE_KEYS = { Enter: 'OK', Escape: 'Back', ' ': 'Play/Pause', Arrows: 'Arrows', UpDown: 'Up, Down', LeftRight: 'Left, Right' };554555// keyHint shows how to press key: its button after a controller was used,556// else its label (on the Android TV app, the remote's); null when the557// controller or the remote has no such button.558const keyHint = (key, label) => {559 if (prompts.pad) return glyph(key, label) || null;560 if (platform === 'tv') return REMOTE_KEYS[key] ? esc(REMOTE_KEYS[key]) : null;561 if (platform === 'touch') return null; // nothing to press562 return esc(label);563};564565// hints joins [key, label, what] into a line of hints, leaving out what the566// controller can't do.567const hints = (items) => items.map(([key, label, what]) => {568 const k = keyHint(key, label);569 return k == null ? '' : `${k} ${what}`;570}).filter(Boolean).join(' | ');571572// renderHints redraws whatever shows keys, after the input changed.573function renderHints() {574 for (const k of $$('#dock kbd')) {575 const h = keyHint(k.dataset.key, k.dataset.label);576 k.innerHTML = h || '';577 k.hidden = h == null;578 }579 renderSettings();580 if (!state.report) return;581 if (state.view === 'guide') { guideShown = []; renderGuide(); }582 if (state.view === 'recordings') renderRecordings();583 renderBanner();584}