1;23// The guide.45// ---------- guide ----------6const FILTERS = [7 ['all', 'All'],8 ['favorites', 'Favorites'],9 ['nextgen', 'NextGen'],10 ['sports', 'Sports'],11 ['movie', 'Movies'],12 ['news', 'News'],13 ['family', 'Family'],14];1516// 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.20function 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}3233function 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}4445function 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}5556// 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.62const guideUI = { zone: 'grid', act: '' };6364function 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}7980const guideActs = () => $$('#guide .gd-actions .act').filter(isShown);8182// guideRefocus puts the focus back on the filters or actions after they83// were drawn again.84function guideRefocus() {85 if (state.view !== 'guide' || state.dock >= 0 || guideUI.zone === 'grid') return;86 if (!$('#guide').contains(document.activeElement)) guideZone(guideUI.zone);87}8889function guideOpenActions() {90 if (state.view === 'guide' && guideChannels()[state.gRow]) guideZone('actions', '-');91}9293function 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 const picked = () => `${state.gRow}|${(selectedProgram(guideChannels()) || {})._s}`;120 const was = guideUI.zone === 'grid' ? picked() : '';121 guideZone('grid');122 state.gRow = Number(cell.dataset.row);123 if (cell.dataset.t) state.gTime = Number(cell.dataset.t);124 renderGuide();125 if (tappedAgain(was, picked())) guideActivate();126 });127 rows.addEventListener('dblclick', (e) => {128 const cell = e.target.closest('[data-row]');129 if (cell) guideActivate();130 });131}132133function guideKey(e) {134 if (guideUI.zone === 'filters' && guideFiltersKey(e)) return;135 if (guideUI.zone === 'actions' && guideActionsKey(e)) return;136 const list = guideChannels();137 const sel = selectedProgram(list);138 switch (e.key) {139 // Up and Down wrap round the channels, so the last ones are a press140 // away. The dock is Left from the earliest program, and the filters141 // Down from the dock.142 case 'ArrowUp': state.gRow = list.length ? (state.gRow - 1 + list.length) % list.length : 0; break;143 case 'ArrowDown': state.gRow = list.length ? (state.gRow + 1) % list.length : 0; break;144 case 'PageUp': state.gRow = state.gRow === 0 ? list.length - 1 : Math.max(0, state.gRow - 8); break;145 case 'PageDown': state.gRow = state.gRow === list.length - 1 ? 0 : Math.min(list.length - 1, state.gRow + 8); break;146 case 'ArrowRight':147 state.gTime = sel ? sel._e : state.gTime + 30 * MIN;148 while (state.gTime >= state.gStart + (state.gSpan - 30) * MIN) state.gStart += 30 * MIN;149 break;150 case 'ArrowLeft': {151 const min = floor30(Date.now()) - 30 * MIN;152 // From the earliest program, Left moves on to the dock.153 if (!sel || sel._s <= min) { e.preventDefault(); return openDock(); }154 state.gTime = Math.max(min, (sel ? sel._s : state.gTime) - 1);155 while (state.gTime < state.gStart && state.gStart > min) state.gStart -= 30 * MIN;156 break;157 }158 case 'Home': state.gStart = floor30(Date.now()); state.gTime = Date.now(); break;159 case 'Enter':160 e.preventDefault();161 // A key's press acts on its keyup, a hold brings up the actions; a162 // controller's A, at once.163 if (e.isTrusted) return holdStart(() => { if (state.view === 'guide' && guideUI.zone === 'grid') guideActivate(); }, guideOpenActions);164 return guideActivate();165 case 'i': case 'I': e.preventDefault(); return guideOpenActions();166 case 'r': e.preventDefault(); recordSelected('once'); return;167 case 'F': e.preventDefault(); toggleFavorite(list[state.gRow]); return;168 case 'h': case 'H': e.preventDefault(); hideChannel(list[state.gRow]); return;169 case 'R': e.preventDefault(); recordSelected('series', false); return;170 case 'n': case 'N': e.preventDefault(); recordSelected('series', true); return;171 default: {172 if (e.key === ']' || e.key === '[') {173 const filters = guideFilters();174 const i = filters.findIndex(([k]) => k === state.guideFilter);175 state.guideFilter = filters[(i + (e.key === ']' ? 1 : filters.length - 1)) % filters.length][0];176 state.gRow = 0;177 break;178 }179 return;180 }181 }182 e.preventDefault();183 renderGuide();184 scrollRowIntoView();185}186187// guideFiltersKey: Left and Right pick a filter (as a tab does), Down or OK188// goes to the grid, Up (or Left from the first) to the dock.189function guideFiltersKey(e) {190 const btns = $$('.g-filters button');191 const i = Math.max(0, btns.findIndex((b) => b.dataset.filter === state.guideFilter));192 switch (e.key) {193 case 'ArrowLeft': case 'ArrowRight': {194 const j = i + (e.key === 'ArrowLeft' ? -1 : 1);195 if (j < 0) { openDock(); break; }196 if (j >= btns.length) break;197 state.guideFilter = btns[j].dataset.filter;198 state.gRow = 0;199 renderGuide();200 $('.g-rows').scrollTop = 0;201 break;202 }203 case 'ArrowUp': openDock(); break;204 case 'ArrowDown': case 'Enter': guideZone('grid'); break;205 default: return false;206 }207 e.preventDefault();208 return true;209}210211// guideActionsKey: Left and Right along the actions, OK presses, Down (or212// Back) goes back to the grid, Up to the dock.213function guideActionsKey(e) {214 const acts = guideActs();215 const el = document.activeElement;216 switch (e.key) {217 case 'ArrowLeft': case 'ArrowRight': focusEl(neighbor(acts, el, e.key === 'ArrowLeft' ? -1 : 1) || (acts.includes(el) ? null : acts[0])); break;218 case 'ArrowUp': openDock(); break;219 case 'ArrowDown': guideZone('grid'); break;220 case 'Enter':221 if (acts.includes(el)) el.click(); else guideZone('actions');222 break;223 default: return false;224 }225 e.preventDefault();226 return true;227}228229// guideActivate is OK on a program: what's on now (or was, a little230// before) is watched on its channel, and a program to come shows what can231// be done with it.232function guideActivate() {233 const list = guideChannels();234 const ch = list[state.gRow];235 if (!ch) return;236 const p = selectedProgram(list);237 if (!p || p._s <= Date.now()) watchChannel(ch);238 else guideOpenActions();239}240241// watchChannel goes back to TV on a channel. Choosing what's already242// playing goes back to it without tuning again (and losing the rewind243// buffer); a failed channel retries.244function watchChannel(ch) {245 if (!ch) return;246 const playing = state.current && state.current.key === ch.key && !state.recording && $('#nosignal').hidden;247 if (!playing) tune(ch);248 setView('tv');249 if (playing) showBanner();250}251252function selectedProgram(list) {253 const ch = list[state.gRow];254 if (!ch) return null;255 const t = Math.max(state.gTime, state.gStart);256 return airingAt(ch, t) || null;257}258259function scrollRowIntoView(center) {260 const rows = $('.g-rows');261 const rowH = 58;262 const top = state.gRow * rowH;263 if (center) rows.scrollTop = top - rows.clientHeight / 2 + rowH;264 else if (top < rows.scrollTop) rows.scrollTop = top;265 else if (top + rowH > rows.scrollTop + rows.clientHeight) rows.scrollTop = top + rowH - rows.clientHeight;266}267268// guideShown is what the guide's rows were last built from. They are built269// again only when that changes; moving the selection moves two classes, as270// rebuilding thousands of cells on every key is slow on a TV box.271let guideShown = [];272273function renderGuide() {274 if (!state.report) return;275 const g = $('#guide');276 const list = guideChannels();277 state.gRow = Math.min(state.gRow, Math.max(0, list.length - 1));278 const rows = $('.g-rows');279 const lo = state.gStart;280 const now = Date.now();281 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];282 if (shown.every((v, i) => v === guideShown[i])) return moveGuideSelection(list, lo);283 guideShown = shown;284 const chw = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--chw')) || 260;285 const width = Math.max(300, rows.clientWidth - chw);286 const ppm = width / state.gSpan;287 const hi = lo + state.gSpan * MIN;288289 // filters290 const filters = guideFilters();291 if (!filters.some(([k]) => k === state.guideFilter)) state.guideFilter = 'all';292 $('.g-filters').innerHTML = filters.map(([k, label]) => {293 const saved = state.guideFilter;294 state.guideFilter = k;295 const n = guideChannels().length;296 state.guideFilter = saved;297 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>`;298 }).join('');299 $$('.g-filters button').forEach((b) => b.addEventListener('click', () => {300 state.guideFilter = b.dataset.filter;301 state.gRow = 0;302 renderGuide();303 }));304305 // time header306 const d = new Date(lo);307 const today = new Date(now).toDateString() === d.toDateString();308 f(g, 'day').textContent = today ? 'Today' : d.toLocaleDateString([], { weekday: 'long' });309 let ticks = '';310 for (let t = lo; t < hi; t += 30 * MIN) ticks += `<span style="left:${(t - lo) / MIN * ppm}px">${clock(t)}</span>`;311 $('.g-ticks').innerHTML = ticks;312313 // rows314 const sel = selectedProgram(list);315 let html = '';316 list.forEach((ch, r) => {317 // A channel's measured signal shows only when it explains a bad picture.318 const sig = ch.own ? null : sigOf(sigFor(ch));319 const bad = sig && trouble(sig);320 const cls = ['g-row', r === state.gRow ? 'sel' : '', state.current && ch.key === state.current.key ? 'cur' : ''].join(' ');321 html += `<div class="${cls}"><div class="g-ch" data-row="${r}">322 <div class="num${ch.number.length > 4 ? ' long' : ''}">${esc(ch.number)}</div>323 <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>324 ${ch.logoDark ? `<div class="logo own" style="background-image:url('${esc(ch.logoDark)}')"></div>`325 : ch.logo ? `<div class="logo${ch.own ? ' own' : ''}" style="background-image:url('${esc(ch.logo)}')"></div>` : '<div></div>'}326 </div><div class="g-progs">`;327 const progs = programsFor(ch).filter((p) => p._e > lo && p._s < hi);328 if (!progs.length) {329 const label = ch.weather ? 'Local forecast, around the clock' : ch.custom ? ch.description || 'Nothing scheduled yet' : 'No listings';330 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>`;331 }332 for (const p of progs) {333 const left = Math.max(0, (p._s - lo) / MIN * ppm);334 const right = Math.min(width, (p._e - lo) / MIN * ppm);335 const w = Math.max(0, right - left - 3);336 const isSel = r === state.gRow && sel === p;337 const rec = state.dvrKeys.get(airingKey(ch, p));338 const recCls = rec ? (rec.status === 'unavailable' || rec.status === 'conflict' ? 'rec rec-x' : rec.status === 'recording' ? 'rec rec-on' : 'rec') : '';339 const c = ['g-cell', p._e <= now ? 'past' : '', p._s <= now && now < p._e ? 'now' : '', isSel ? 'sel' : '', recCls].join(' ');340 const tag = (p.flags || []).includes('Live') ? '<span class="tag">LIVE </span>' : (p.flags || []).includes('New') ? '<span class="tag">NEW </span>' : '';341 const sub = p.episodeTitle || `${clock(p._s)} to ${clock(p._e)}`;342 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>`;343 }344 html += '</div></div>';345 });346 const keep = rows.scrollTop;347 rows.innerHTML = html || '<div style="padding:28px;color:var(--muted)">No channels match this filter.</div>';348 rows.scrollTop = keep;349350 // now line351 const nl = $('.g-now');352 if (now >= lo && now < hi) {353 nl.style.display = '';354 nl.style.left = `${chw + (now - lo) / MIN * ppm}px`;355 } else nl.style.display = 'none';356357 renderGuideDetail(list[state.gRow], sel);358 f(g, 'status').innerHTML = `${list.length} channels | ${guideStatusHints()}`;359 guideRefocus();360}361362// guideStatusHints names the guide's keys beside its filters.363function guideStatusHints() {364 if (onRemote()) return 'Filters: Left to the top bar, then Down';365 return hints([['i', 'I', 'more'], ['', 'Shift F', 'favorite'], ['h', 'H', 'hide'], ['[', '[ ]', 'filter']]);366}367368// moveGuideSelection marks the selected row and program in rows already369// built, as renderGuide would.370function moveGuideSelection(list, lo) {371 const rows = $('.g-rows');372 const sel = selectedProgram(list);373 for (const el of rows.querySelectorAll('.sel')) el.classList.remove('sel');374 const row = list.length ? rows.children[state.gRow] : null;375 if (row) {376 row.classList.add('sel');377 const cell = sel ? row.querySelector(`.g-cell[data-t="${Math.max(sel._s, lo)}"]`) : row.querySelector('.g-cell.empty');378 if (cell) cell.classList.add('sel');379 }380 renderGuideDetail(list[state.gRow], sel);381}382383function humanMinutes(m) {384 if (m < 60) return `${m} min`;385 const h = Math.floor(m / 60);386 return m % 60 ? `${h} h ${m % 60} min` : `${h} h`;387}388389// renderGuideActions draws the selected program's actions: Watch first for390// what's on now, the recording ones where recording applies (the391// antenna's channels, on a server with a DVR), then the channel's.392function renderGuideActions(ch, p) {393 const box = f($('#guide'), 'actions');394 if (!ch) { box.innerHTML = ''; return; }395 // Laid out, it may need less shown beside it.396 requestAnimationFrame(fitGuideDetail);397 const now = Date.now();398 const airing = !p || p._s <= now;399 const act = (name, label, cls = '') => `<button type="button" class="act fx${cls ? ` ${cls}` : ''}" data-act="${name}">${label}</button>`;400 const parts = [];401 if (airing) parts.push(act('watch', !p || now < p._e ? 'Watch' : `Watch ${esc(ch.number)}`));402 if (state.info.dvr && p && p._e > now && !ch.weather && !ch.custom) {403 const it = state.dvrKeys.get(airingKey(ch, p));404 const rule = seriesRuleFor(ch, p);405 if (it) {406 const label = { recording: 'Recording now', unavailable: 'Channel not available', conflict: 'Conflict: every tuner records then' }[it.status] || 'Recording scheduled';407 parts.push(`<span class="chip ${it.status === 'unavailable' || it.status === 'conflict' ? 'warn' : 'live'}">${label}</span>`);408 parts.push(act('once', it.status === 'recording' ? 'Stop recording' : 'Cancel'));409 } else if (!rule && p.seriesId) {410 // One Record, which opens to this airing, every episode or new ones;411 // another program closes it.412 if (state.recPick !== airingKey(ch, p)) state.recPick = '';413 parts.push(state.recPick === airingKey(ch, p)414 ? act('once', '<i></i>This one', 'rec') + act('series', 'Every episode') + act('new', 'New only')415 : act('record', '<i></i>Record', 'rec'));416 } else {417 parts.push(act('once', '<i></i>Record', 'rec'));418 }419 if (rule) {420 parts.push(`<span class="chip new">Series${rule.newOnly ? ', new only' : ''}</span>${act('series', 'Stop series')}`);421 }422 }423 if (!airing) parts.push(act('watch', `Watch ${esc(ch.number)}`));424 parts.push(act('fav', isFav(ch) ? 'Unfavorite' : 'Favorite'));425 parts.push(act('hide', 'Hide channel'));426 box.innerHTML = parts.join('');427 guideRefocus();428}429430// fitGuideDetail leaves out what of the program's details doesn't fit431// above the grid at this window and interface size: its cast first, then432// its description, its tags, its episode line and the hint. The title and the433// actions always show, the actions on one row that scrolls sideways when434// even that isn't enough.435function fitGuideDetail() {436 const g = $('#guide');437 const top = $('.g-top', g);438 const box = $('.g-detail', g);439 // The picture needs the details to keep a readable width beside it.440 top.classList.toggle('no-art', top.clientWidth < 1100);441 const parts = ['cast', 'desc', 'meta', 'ep', 'hint'].map((k) => f(g, k));442 for (const el of parts) el.classList.remove('gd-gone');443 box.classList.remove('one-row');444 const cs = getComputedStyle(top);445 const room = top.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);446 for (const el of parts) {447 if (box.offsetHeight <= room) return;448 el.classList.add('gd-gone');449 }450 if (box.offsetHeight > room) box.classList.add('one-row');451}452453// openRecordPick opens Record into its three choices, the first focused.454function openRecordPick(ch, p) {455 if (!ch || !p) return;456 state.recPick = airingKey(ch, p);457 renderGuideActions(ch, p);458 focusEl(f($('#guide'), 'actions').querySelector('[data-act="once"]'));459}460461// closeRecordPick folds the choices back into Record, focused.462function closeRecordPick() {463 state.recPick = '';464 const list = guideChannels();465 renderGuideActions(list[state.gRow], selectedProgram(list));466 const rec = f($('#guide'), 'actions').querySelector('[data-act="record"]');467 if (rec && guideUI.zone === 'actions') focusEl(rec);468}469470function recordSelected(kind, newOnly) {471 const list = guideChannels();472 return recordAiring(list[state.gRow], selectedProgram(list), kind, newOnly);473}474475// recordNow records what is on the channel being watched.476function recordNow(kind, newOnly) {477 const ch = state.current;478 if (state.recording || !ch) return;479 const p = airingAt(ch, Date.now());480 if (!p) return toast('No listing to record on this channel');481 return recordAiring(ch, p, kind, newOnly).then(renderBanner);482}483484async function recordAiring(ch, p, kind, newOnly) {485 if (!state.info.dvr) return toast('Recording is not available on this server');486 if (!ch || !p) return;487 if (ch.weather) return toast('The weather channel is always on; nothing to record');488 if (ch.custom) return toast(`${ch.name} is made by your server; nothing to record`);489 if (p._e <= Date.now()) return toast('That has already aired');490 const it = state.dvrKeys.get(airingKey(ch, p));491 const rule = seriesRuleFor(ch, p);492 try {493 if (kind === 'once' && it && it.status === 'recording') {494 await api().StopRecording(it.id);495 toast(`Stopped recording ${p.title}; what was recorded is in the library`);496 } else if (kind === 'once' && it) {497 await api().DeleteRecording(it.id);498 toast(`Won't record ${p.title}`);499 } else if (kind === 'series' && rule) {500 await api().DeleteRule(rule.id);501 toast(`Stopped the series recording of ${p.title}`);502 } else if (kind === 'series' && !p.seriesId) {503 return toast(`${p.title} has no series information. Press R to record this airing.`);504 } else {505 await api().Record({ kind, channel: ch.number, callSign: ch.callSign, start: p.start, newOnly: !!newOnly });506 const aired = Math.round((Date.now() - p._s) / MIN);507 toast(kind === 'series'508 ? `Recording ${newOnly ? 'new episodes' : 'every episode'} of ${p.title} on ${ch.number}`509 : aired >= 1 ? `Recording ${p.title} from now; the first ${aired} minute${aired > 1 ? 's' : ''} already aired`510 : `Recording ${p.title} at ${clock(p._s)} on ${ch.number}`);511 }512 } catch (e) {513 toast(String(e && e.message ? e.message : e), 6000);514 }515 await loadDVR();516}517518function renderGuideDetail(ch, p) {519 const g = $('#guide');520 if (!ch) {521 // No channels under this filter.522 for (const k of ['kicker', 'title', 'ep', 'meta', 'cast', 'desc', 'actions', 'hint']) f(g, k).textContent = '';523 f(g, 'art').classList.remove('has');524 return;525 }526 const now = Date.now();527 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>`;528 f(g, 'title').textContent = p ? p.title : ch.own ? ch.name : 'No listings';529 f(g, 'ep').textContent = episodeLine(p);530 // An own channel's category is among its programs' genres; with nothing531 // listed it shows on its own, with the channel's description.532 const genres = p ? p.genres || [] : ch.own && ch.category !== 'Other' ? [ch.category] : [];533 f(g, 'meta').innerHTML = chipsFor(p, ch) + genres.map((x) => `<span class="chip">${esc(x)}</span>`).join('');534 f(g, 'cast').textContent = castLine(p);535 f(g, 'desc').textContent = p ? p.description || '' : ch.description || '';536 const live = !p || p._s <= now;537 const ok = keyHint('Enter', 'Enter');538 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`;539 const more = !live ? '' : prompts.pad ? hints([['i', 'I', 'for more']]) : `hold ${ok} for more`;540 const rec = state.info.dvr && p && p._e > now && !ch.weather && !ch.custom && onKeys() ? ' | R record, Shift R series, N new only' : '';541 f(g, 'hint').innerHTML = `${when}${more ? `, ${more}` : ''}${rec}`;542 renderGuideActions(ch, p);543 const art = f(g, 'art');544 if (p && p.image) { art.style.backgroundImage = `url('${imageURL(p.image)}')`; art.classList.add('has'); } else { art.style.backgroundImage = ''; art.classList.remove('has'); }545}