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 / player.js
player.js 27.6K
1'use strict';23// Tuning and playing a channel, the banner and the on-screen controls.45// ---------- tuning & playback ----------6// at is when the viewer asked for the channel (a key press), for timing7// the change.8async 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}5152// 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.55function 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}6162// reportTune tells the server how long a channel change took, from the63// key to the picture playing, for the times on its admin page.64function 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}6869function resetPlayer() {70 state.liveBaseline = null;71 if (state.hls) { state.hls.destroy(); state.hls = null; }72 video.removeAttribute('src');73 video.load();74}7576async 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}99100function 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 // A touch screen's next tap, which lets sound play, turns it on: once132 // it's a tap. As a finger lands the browser doesn't yet count it as133 // the user's, and unmuted then, the picture would stop.134 if (platform === 'touch') {135 document.addEventListener('click', () => { if (!state.userMuted) video.muted = false; }, { once: true, capture: true });136 return toast('Started muted. Tap for sound.', 3500);137 }138 const m = keyHint('m', 'M');139 if (m) return toast(`Started muted. Press ${m} for sound.`, 3500, true);140 // A remote has no M: its next key, which lets sound play, turns it on.141 document.addEventListener('keydown', () => { if (!state.userMuted) video.muted = false; }, { once: true, capture: true });142 toast('Started muted. Press any key for sound.', 3500);143 }144 });145 });146}147148function showNoSignal(ch, msg) {149 resetPlayer();150 const ns = $('#nosignal');151 f(ns, 'num').textContent = ch.number;152 f(ns, 'call').textContent = `${displayCall(ch)} ${ch.network ? '| ' + ch.network : ''}`;153 f(ns, 'msg').textContent = msg;154 ns.hidden = false;155}156157function step(dir) {158 const list = state.lineup;159 if (!list.length) return;160 let i = list.findIndex((c) => c.key === (state.current && state.current.key));161 i = (i + dir + list.length) % list.length;162 tune(list[i]);163}164165// ---------- banner ----------166function chipsFor(p, ch) {167 const out = [];168 for (const fl of (p && p.flags) || []) out.push(`<span class="chip ${fl === 'Live' ? 'live' : fl === 'New' ? 'new' : ''}">${esc(fl)}</span>`);169 if (p && p.stars) out.push(`<span class="chip" title="Gracenote's rating, out of 4">${stars(p.stars)}</span>`);170 if (p && p.rating) out.push(`<span class="chip">${esc(p.rating)}</span>`);171 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>`);172 for (const t of (p && p.tags) || []) out.push(`<span class="chip">${esc(t)}</span>`);173 return out.join('');174}175176// imageURL is a picture's address: the server's own ("/guide-art/...")177// relative to the server, others as they are.178const imageURL = (u) => (u && u.startsWith('/') ? state.boot.serverUrl + u : u || '');179180// stars draws a rating out of 4 in stars, halves included.181const stars = (n) => '\u2605'.repeat(Math.floor(n)) + (n % 1 >= 0.5 ? '\u00bd' : '');182183function episodeLine(p) {184 if (!p) return '';185 const se = p.season && p.episode ? `S${p.season} E${p.episode}` : '';186 // A repeat says when it first aired, which says the year too.187 const first = p.originalAirDate && !(p.flags || []).includes('New') ? `first aired ${firstAired(p.originalAirDate)}` : '';188 const game = p.awayTeam && p.homeTeam && !p.episodeTitle ? `${p.awayTeam} at ${p.homeTeam}` : '';189 return [p.episodeTitle && `"${p.episodeTitle}"`, game, se, !first && p.year, first].filter(Boolean).join(' ');190}191192// firstAired names an original air date ("2019-03-05") as "Mar 5, 2019".193function firstAired(d) {194 const [y, m, day] = d.split('-').map(Number);195 return new Date(y, m - 1, day).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });196}197198// castLine names a program's cast: "With Missy Peregrym, Zeeko Zaki".199const castLine = (p, n = 5) => (p && p.cast && p.cast.length ? `With ${p.cast.slice(0, n).map((c) => c.replace(/ \(.*\)$/, '')).join(', ')}` : '');200201function renderBanner() {202 updateMediaSession();203 showTimeshift();204 renderOSD();205 const ch = state.current;206 const b = $('#banner');207 if (state.recording) return (state.recording.kind === 'episode' ? renderEpisodeBanner : renderRecordingBanner)(b, state.recording);208 if (!ch) return;209 if (ch.weather) return renderWXBanner(b, ch);210 const now = Date.now();211 const p = airingAt(ch, now);212 const nx = p ? nextAfter(ch, p._e - 1) : nextAfter(ch, now);213 f(b, 'num').textContent = ch.number;214 f(b, 'num').classList.toggle('long', ch.number.length > 4);215 f(b, 'call').textContent = displayCall(ch);216 f(b, 'net').textContent = ch.network || '';217 showChannelLogo(b, ch);218 f(b, 'time').textContent = p ? `${clock(p._s)} to ${clock(p._e)}` : '';219 // What's on is being recorded, or will be.220 const rec = p && state.dvrKeys.get(airingKey(ch, p));221 f(b, 'chips').innerHTML = (rec ? `<span class="chip live">${rec.status === 'recording' ? 'Recording' : 'Will record'}</span>` : '') + chipsFor(p, ch);222 f(b, 'title').textContent = p ? p.title : 'No listings';223 f(b, 'ep').textContent = episodeLine(p);224 f(b, 'cast').textContent = castLine(p, 3);225 f(b, 'progress').style.width = p ? `${Math.min(100, ((now - p._s) / (p._e - p._s)) * 100)}%` : '0';226 f(b, 'desc').textContent = p ? p.description || '' : ch.description || '';227 f(b, 'next').textContent = nx ? `${clock(nx._s)} ${nx.title}` : '';228229 f(b, 'note').textContent = state.note || '';230231 // The server's own channels have no reception to show. The meter is232 // rebuilt on the next antenna channel.233 if (ch.custom) {234 b.classList.remove('warn');235 f(b, 'meter').hidden = true;236 f(b, 'tier').textContent = ch.category && ch.category !== 'Other' ? ch.category : '';237 f(b, 'tier').style.color = '';238 f(b, 'tx').textContent = OWN_SOURCE[ch.kind] || OWN_SOURCE.folder;239 f(b, 'atsc3').textContent = '';240 return;241 }242243 // The signal is shown when asked for (I twice, or the controls' I) or244 // when what was measured explains a bad picture.245 const sig = sigOf(sigFor(ch));246 b.classList.toggle('warn', trouble(sig));247 f(b, 'meter').outerHTML = meter(sig, true).replace('class="meter big', 'data-f="meter" class="meter big');248 f(b, 'meter').hidden = false;249 f(b, 'tier').textContent = sig.label;250 f(b, 'tier').style.color = sig.state === 'none' ? '' : sig.color;251 const st = stationFor(ch);252 const mux = state.byRF.get(ch.rf);253 const where = ch.rf ? `RF ${ch.rf} ${(mux && mux.band) || (st && st.band) || ''}`.trim() : '';254 const lines = [];255 if (st) {256 lines.push(`${ch.via ? `On ${esc(st.callSign)}'s transmitter, ` : `${esc(st.callSign)} `}${where}`);257 lines.push(`${st.distanceKm.toFixed(0)} km ${compass(st.bearingDeg)}, ${st.erpKw >= 10 ? st.erpKw.toFixed(0) : st.erpKw.toFixed(1)} kW`);258 } else if (where) {259 lines.push(`${where}, no licensed transmitter on record`);260 }261 lines.push(esc(sigDetail(sig)));262 f(b, 'tx').innerHTML = lines.join('<br>');263 f(b, 'atsc3').textContent = ch.atsc3 ? `Also in ATSC 3.0 on ${ch.atsc3.hostCall} RF ${ch.atsc3.rf}` : '';264}265266// What the server's own channels play, for the banner's details.267const OWN_SOURCE = {268 folder: 'Videos from your server, on a loop',269 jellyfin: 'From your Jellyfin library, on a schedule',270 youtube: 'YouTube uploads, streamed as they air',271};272273// showChannelLogo puts an own channel's logo under its number in the274// banner; stations' logos are for the guide.275function showChannelLogo(b, ch) {276 const el = f(b, 'logo');277 if (!el) return;278 const logo = ch && ch.own && !state.recording ? ch.logo : '';279 el.hidden = !logo;280 el.style.backgroundImage = logo ? `url('${logo}')` : '';281}282283// The banner shows briefly on a channel change and on request. Pressing I284// cycles: banner, banner with the measured signal, hidden. With the285// on-screen controls up it stays as long as they do.286function showBanner(detail = false) {287 renderBanner();288 const b = $('#banner');289 b.classList.toggle('detail', detail);290 b.classList.add('show');291 if (osd.open) return osdTouch();292 return hideBannerSoon(detail ? 12000 : 4000);293}294295// hideBanner puts the banner away, and the controls with it.296function hideBanner() {297 clearTimeout(state.bannerTimer);298 closeOSD();299 $('#banner').classList.remove('show', 'detail');300}301302function hideBannerSoon(ms = 4000) {303 clearTimeout(state.bannerTimer);304 if (osd.open) return osdTouch();305 state.bannerTimer = setTimeout(hideBanner, ms);306 return undefined;307}308309// cycleBanner is the info key. It also flashes where the picture is, in310// the playback badge. With the controls up it shows or hides reception.311function cycleBanner() {312 const b = $('#banner');313 if (osd.open) {314 if (!state.recording) b.classList.toggle('detail');315 return flashPlace();316 }317 if (b.classList.contains('show') && (b.classList.contains('detail') || state.recording)) return hideBanner();318 flashPlace();319 return showBanner(b.classList.contains('show'));320}321322// ---------- on-screen controls ----------323// OK on live TV brings up the controls over the picture, as video players324// on a TV do (Media3's PlayerControlView, Leanback, Kodi's OSD, the Live325// TV app's menu): the channel banner, a timeline of the program and of what326// can be rewound, a row of labeled buttons, and the dock above as the way327// to every view. The focus starts on Guide, so OK twice opens the guide;328// Left and Right on live TV skip and bring them up with the timeline329// picked. Up and Down move between the rows, Left and Right along one, OK330// presses. They go after OSD_MS without a key, but stay while paused (as331// Media3's and Leanback's do); Back puts them away.332const OSD_MS = 6000;333const osd = { open: false, timer: 0, tick: 0, last: 'guide', lastButton: 'guide', hover: false };334335// The buttons, in order: [action, label]. Labels change with the state.336const OSD_BUTTONS = [337 ['guide', 'Guide'], ['play', 'Pause'], ['back', '-10 s'], ['ahead', '+30 s'], ['live', 'Live'],338 ['captions', 'Captions'], ['audio', 'Audio'], ['record', 'Record'], ['favorite', 'Favorite'], ['last', 'Last channel'],339];340const OSD_KEYS = { guide: 'G', play: 'Space', back: 'Left', ahead: 'Right', live: 'End', captions: 'C', audio: 'V', record: 'R', favorite: 'Shift F', last: 'L' };341const ICON_TEXT = 'text-anchor="middle" font-family="Plex Mono, Menlo, monospace" font-weight="700"';342const OSD_ICONS = {343 guide: '<path d="M3 4h18v3.5H3zm0 6.25h7.5v3.5H3zm9.5 0H21v3.5h-8.5zM3 16.5h11V20H3zm13 0h5V20h-5z"/>',344 play: '<path d="M7 4.5v15L19.5 12z"/>',345 pause: '<path d="M6 4.5h4.2v15H6zm7.8 0H18v15h-4.2z"/>',346 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>`,347 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>`,348 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"/>',349 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"/>',350 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>`,351 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"/>',352 record: '<circle cx="12" cy="12" r="7"/>',353 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"/>',354 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"/>',355 last: '<path d="M8 3.5 3 8l5 4.5V9.3h9V6.7H8zM16 11.5v3.2H7v2.6h9v3.2l5-4.5z"/>',356};357358const timelineEl = () => f($('#banner'), 'timeline');359const osdButtons = () => $$('#banner .osd-bar .ob').filter((b) => !b.hidden);360361function wireOSD() {362 const b = $('#banner');363 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('');364 f(b, 'osd').addEventListener('click', (e) => {365 const btn = e.target.closest('[data-act]');366 if (!btn) return;367 osdTouch();368 osdAct(btn.dataset.act);369 });370 b.addEventListener('focusin', (e) => {371 const btn = e.target.closest('.ob');372 if (btn) osd.last = osd.lastButton = btn.dataset.act;373 else if (e.target === timelineEl()) osd.last = 'timeline';374 });375 // The pointer on the controls keeps them up.376 b.addEventListener('mouseenter', () => { osd.hover = document.body.classList.contains('mouse-active'); osdTouch(); });377 b.addEventListener('mouseleave', () => { osd.hover = false; osdTouch(); });378 // A click on the timeline goes there.379 timelineEl().addEventListener('click', (e) => {380 const bar = $('.tl-bar', timelineEl()).getBoundingClientRect();381 if (bar.width > 0) seekTimeline((e.clientX - bar.left) / bar.width);382 });383}384385// openOSD brings up the controls, with the focus on focus ("guide", another386// button's action, or "timeline"), or on none for a pointer.387function openOSD(focus = 'guide') {388 if (state.view !== 'tv' || (!state.current && !state.recording)) return;389 if (!osd.open) {390 osd.open = true;391 document.body.classList.add('osd-open');392 clearTimeout(state.bannerTimer);393 osd.tick = setInterval(renderTimeline, 1000);394 }395 renderBanner();396 $('#banner').classList.add('show', 'osd');397 if (focus) osdFocus(focus);398 osdTouch();399}400401function closeOSD() {402 if (!osd.open) return;403 osd.open = false;404 clearTimeout(osd.timer);405 clearInterval(osd.tick);406 osd.tick = 0;407 osd.hover = false;408 document.body.classList.remove('osd-open');409 const b = $('#banner');410 b.classList.remove('osd', 'tl-on');411 if (state.dock >= 0 && state.view === 'tv') closeDock(false);412 blurIn(b);413}414415// osdTouch starts the controls' time over: a key was pressed.416function osdTouch() {417 clearTimeout(osd.timer);418 if (!osd.open || osd.hover || pausedStream()) return;419 osd.timer = setTimeout(hideBanner, OSD_MS);420}421422const hasPicture = () => !!(video.src || state.hls) && !video.hidden && $('#nosignal').hidden;423const pausedStream = () => hasPicture() && video.paused;424425// osdFocus focuses a button by its action, or the timeline: the nearest426// there is when that one isn't showing.427function osdFocus(target) {428 const tl = timelineEl();429 if (target === 'timeline' && !tl.hidden) return focusEl(tl);430 const buttons = osdButtons();431 const want = target === 'timeline' ? osd.lastButton : target;432 return focusEl(buttons.find((b) => b.dataset.act === want) || buttons.find((b) => b.dataset.act === 'play') || buttons[0]);433}434435// osdKey handles a key while the controls are up; it reports whether it436// did. Keys it leaves (letters, Space, channel up and down) do what they437// do on TV.438function osdKey(e) {439 const k = e.key;440 const el = document.activeElement;441 const onTl = el === timelineEl();442 const btn = el && el.closest ? el.closest('#banner .ob') : null;443 if ((k === 'ArrowLeft' || k === 'ArrowRight') && e.shiftKey) {444 e.preventDefault();445 skip(k === 'ArrowLeft' ? -60 : 60);446 renderTimeline();447 return true;448 }449 if (k === 'Escape') {450 // A channel number being typed goes first.451 e.preventDefault();452 if (state.entry) cancelEntry(); else hideBanner();453 return true;454 }455 if (!onTl && !btn) {456 // Up by a pointer, or the focus went: the first key finds it.457 if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter'].includes(k)) return false;458 e.preventDefault();459 osdFocus(osd.last);460 return true;461 }462 switch (k) {463 case 'ArrowLeft': case 'ArrowRight':464 if (onTl) {465 skip(k === 'ArrowLeft' ? -10 : 30);466 renderTimeline();467 } else {468 focusEl(neighbor(osdButtons(), btn, k === 'ArrowLeft' ? -1 : 1));469 }470 break;471 case 'ArrowUp':472 if (btn && !timelineEl().hidden) focusEl(timelineEl());473 else openDock();474 break;475 case 'ArrowDown':476 if (onTl) osdFocus(osd.lastButton);477 break;478 case 'Enter':479 if (onTl) togglePause(); else btn.click();480 break;481 default: return false;482 }483 e.preventDefault();484 return true;485}486487function osdAct(act) {488 const after = (x) => Promise.resolve(x).then(renderOSD);489 switch (act) {490 case 'guide': return setView('guide');491 case 'play': return togglePause();492 case 'back': skip(-10); return renderTimeline();493 case 'ahead': skip(30); return renderTimeline();494 case 'live': return state.recording ? state.current && tune(state.current) : goLive();495 case 'captions': return after(toggleCaptions());496 case 'audio': return after(cycleAudio());497 case 'record': return after(recordNow('once'));498 case 'favorite': return after(toggleFavorite(state.current));499 case 'last': return state.previous && tune(state.previous);500 default: return null;501 }502}503504// renderOSD shows the buttons that apply now, labeled for the state, and505// the timeline. The focus moves on from a button that went.506function renderOSD() {507 if (!osd.open) return;508 const b = $('#banner');509 const ch = state.current;510 const rec = state.recording;511 const now = Date.now();512 // Video controls for a stream, not the weather channel's display or a513 // channel that failed to tune.514 const stream = $('#nosignal').hidden && !!(rec || (ch && !ch.weather));515 const p = !rec && ch && !ch.own ? airingAt(ch, now) : null;516 const it = p && state.dvrKeys.get(airingKey(ch, p));517 const fav = !rec && isFav(ch);518 const was = document.activeElement;519 const before = osdButtons();520 const at = before.indexOf(was);521 const set = (act, on, label, icon = act, lit = false) => {522 const btn = $(`.ob[data-act="${act}"]`, b);523 btn.hidden = !on;524 if (!on) return;525 const span = btn.lastElementChild;526 if (span.textContent !== label) span.textContent = label;527 if (btn.dataset.icon !== icon) {528 btn.dataset.icon = icon;529 btn.firstElementChild.innerHTML = OSD_ICONS[icon];530 }531 btn.classList.toggle('on', lit);532 if (act !== 'guide' && act !== 'last') btn.setAttribute('aria-pressed', String(lit));533 };534 set('guide', true, 'Guide');535 set('play', stream, video.paused && hasPicture() ? 'Play' : 'Pause', video.paused && hasPicture() ? 'play' : 'pause');536 set('back', stream, '-10 s');537 set('ahead', stream, '+30 s');538 set('live', !!rec || stream, rec ? 'Live TV' : 'Live', rec ? 'tv' : 'live');539 set('captions', stream, 'Captions', 'captions', stream && captionsWanted());540 set('audio', stream && audioTracks().length > 1, 'Audio');541 // Recording is for the antenna's channels, on a server with a DVR.542 set('record', !!(state.info.dvr && p && p._e > now), it ? (it.status === 'recording' ? 'Recording' : 'Will record') : 'Record', 'record', !!it);543 set('favorite', !rec && !!ch, 'Favorite', fav ? 'favorited' : 'favorite', fav);544 set('last', !rec && !!state.previous, 'Last channel');545 const last = $('.ob[data-act="last"]', b);546 last.title = state.previous ? `Back to ${state.previous.number} ${displayCall(state.previous)} (L)` : '';547 renderTimeline();548 if (was && b.contains(was) && (was.hidden || document.activeElement !== was)) {549 const after = osdButtons();550 if (was === timelineEl() || at < 0) osdFocus(osd.lastButton);551 else focusEl(after[Math.min(at, after.length - 1)]);552 }553}554555// timelineModel is what the timeline shows, in one scale: for live TV, ms556// since the epoch, from the program's start (or whatever can be rewound557// before it) to its end; for a recording, seconds into it.558function timelineModel() {559 if (!hasPicture()) return null;560 const r = video.seekable;561 const has = r && r.length > 0;562 if (state.recording) {563 const off = state.recOffset || 0;564 const pos = off + (video.currentTime || 0);565 const dur = Math.max(state.recording.duration || 0, pos);566 if (!dur) return null;567 return {568 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,569 left: mmss(pos), right: mmss(dur), at: mmss(pos), live: false,570 };571 }572 const now = Date.now();573 const behind = behindLive();574 const head = now - behind * 1000;575 const bufLo = has ? now - Math.max(0, livePoint() - r.start(0)) * 1000 : head;576 const p = airingAt(state.current, now);577 const lo = Math.min(p ? p._s : now - 15 * MIN, bufLo, head);578 const hi = p ? p._e : now;579 const live = behind <= 4;580 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 };581}582583function renderTimeline() {584 if (!osd.open) return;585 const b = $('#banner');586 const tl = timelineEl();587 const m = timelineModel();588 const show = !!m;589 if (tl.hidden === show) {590 // The timeline goes (a failed tune, the weather channel): its focus591 // moves down to the buttons.592 const had = document.activeElement === tl;593 tl.hidden = !show;594 if (had && !show) osdFocus(osd.lastButton);595 }596 b.classList.toggle('tl-on', show);597 const liveBtn = $('.ob[data-act="live"]', b);598 if (liveBtn) liveBtn.classList.toggle('at', !!(m && m.live));599 if (!m) return;600 const span = m.hi - m.lo || 1;601 const pct = (t) => `${Math.max(0, Math.min(100, ((t - m.lo) / span) * 100)).toFixed(2)}%`;602 const bar = $('.tl-bar', tl);603 const [past, buf, edge, head] = bar.children;604 past.style.width = pct(m.past);605 buf.style.left = pct(m.bufLo);606 buf.style.width = `calc(${pct(m.bufHi)} - ${pct(m.bufLo)})`;607 edge.hidden = m.edge == null;608 if (m.edge != null) edge.style.left = pct(m.edge);609 head.style.left = pct(m.head);610 f(tl, 'tl-lo').textContent = m.left;611 f(tl, 'tl-hi').textContent = m.right;612 f(tl, 'tl-at').textContent = m.at;613 tl.setAttribute('aria-valuetext', m.at);614}615616// seekTimeline goes to a fraction of the timeline (a click on it).617function seekTimeline(frac) {618 const m = timelineModel();619 const r = video.seekable;620 if (!m || !r || !r.length) return;621 const t = m.lo + Math.max(0, Math.min(1, frac)) * (m.hi - m.lo);622 // Both scales count forward with the picture: ms for live, s for a recording.623 const delta = state.recording ? t - m.head : (t - m.head) / 1000;624 const from = video.currentTime;625 video.currentTime = Math.min(Math.max(from + delta, r.start(0)), state.recording ? r.end(r.length - 1) : livePoint());626 showTimeshift();627 renderTimeline();628 osdTouch();629}630631// ---------- number entry ----------632function entryKey(k) {633 state.entry = (state.entry + k).slice(0, 6);634 const e = $('#entry');635 e.textContent = state.entry;636 e.classList.add('show');637 clearTimeout(state.entryTimer);638 state.entryTimer = setTimeout(commitEntry, 2200);639}640641function commitEntry() {642 clearTimeout(state.entryTimer);643 const n = state.entry;644 state.entry = '';645 $('#entry').classList.remove('show');646 if (!n) return;647 const ch = findChannel(n);648 if (ch) tune(ch); else toast(`No channel ${n} in your lineup`);649}650651function cancelEntry() {652 clearTimeout(state.entryTimer);653 state.entry = '';654 $('#entry').classList.remove('show');655}