1;23// Live rewind, the heartbeat, a recording's progress, playback's state and the system's media controls.45// ---------- live rewind, captions and audio ----------6function liveEdge() {7 const r = video.seekable;8 return r && r.length ? r.end(r.length - 1) : video.duration || 0;9}1011// livePoint is where live TV plays from, and what counts as live: hls.js's12// place a little behind the edge, where it has the video (at the edge13// itself it has none yet, and WebKit drops back to the start), else the14// edge.15function livePoint() {16 const h = state.hls;17 if (h) {18 const p = h.liveSyncPosition;19 if (Number.isFinite(p) && p > 0) return Math.min(p, liveEdge());20 if (Number.isFinite(h.targetLatency)) return Math.max(0, liveEdge() - h.targetLatency);21 }22 return liveEdge();23}2425function skip(seconds) {26 if (!video.src && !state.hls) return;27 const r = video.seekable;28 if (!r || !r.length) return;29 const from = video.currentTime;30 const t = Math.min(Math.max(from + seconds, r.start(0)), livePoint());31 video.currentTime = t;32 noteSkip(t - from, seconds);33 showTimeshift();34}3536function togglePause() {37 if (video.paused && state.staleStream) return restartStream();38 if (video.paused) video.play().catch(() => {}); else video.pause();39 showTimeshift();40}4142// ---------- heartbeat ----------43// While a stream is open, playing or paused, the app says so every 30 s:44// the server stops streams nobody fetches from for 2 minutes, freeing45// their tuners. One it stopped anyway (the app was asleep, or in the46// background) starts again at once if it was playing on TV, else on the47// next Play or return to TV.48async function heartbeat() {49 if (!(video.src || state.hls) || state.staleStream) return;50 let alive = true;51 try {52 alive = await api().Heartbeat();53 } catch (e) {54 return; // the server is away; the player has its own errors55 }56 if (alive) return;57 log('info', 'the server had stopped the stream');58 if (state.view === 'tv' && !video.paused) restartStream();59 else state.staleStream = true;60}6162// restartStream starts the stream on again: the channel, or the recording63// from where it is.64function restartStream() {65 state.staleStream = false;66 if (state.recording) {67 const it = state.recording;68 return playOnDemand(it, (state.recOffset || 0) + (video.currentTime || 0));69 }70 return state.current ? tune(state.current, { quiet: true }) : undefined;71}7273function goLive() {74 if (state.recording) return;75 video.currentTime = livePoint();76 video.play().catch(() => {});77 flashPlace(true);78 showTimeshift();79}8081// behindLive is how far playback trails live: hls.js's live point, or for82// the webview's own HLS the edge less the delay measured when playback83// started.84function behindLive() {85 if (state.recording || !video.seekable || !video.seekable.length) return 0;86 if (state.hls) return Math.max(0, livePoint() - video.currentTime);87 return Math.max(0, liveEdge() - video.currentTime - (state.liveBaseline || 0));88}8990const mmss = (sec) => {91 sec = Math.max(0, Math.round(sec));92 const h = Math.floor(sec / 3600);93 const m = Math.floor((sec % 3600) / 60);94 const s = String(sec % 60).padStart(2, '0');95 return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;96};9798// showTimeshift says in the banner's time line how far behind live the99// picture is, or where in a recording.100function showTimeshift() {101 let text = '';102 if (!video.hidden && (video.src || state.hls)) {103 if (state.recording) {104 text = `${video.paused ? 'Paused, ' : ''}${mmss((state.recOffset || 0) + video.currentTime)} of ${mmss(state.recording.duration || 0)}`;105 } else {106 const behind = behindLive();107 if (video.paused) text = behind > 2 ? `Paused, ${mmss(behind)} behind live` : 'Paused';108 else if (behind > 4) text = `${mmss(behind)} behind live`;109 }110 }111 const el = f($('#banner'), 'shift');112 if (el.textContent !== text) el.textContent = text;113}114115// playOnDemand plays a recording, or a podcast's episode (kind 'episode'),116// from a point in it, on TV; the player shows positions in the whole.117async function playOnDemand(it, from = 0) {118 if (!it || it.status === 'failed') return toast('That recording failed and cannot be played');119 const token = ++state.tuneToken;120 state.staleStream = false;121 state.recording = it;122 showWX(false);123 setView('tv');124 stage.classList.add('tuning');125 $('#nosignal').hidden = true;126 showBanner();127 if (state.hls) state.hls.stopLoad();128 try {129 const pb = await (it.kind === 'episode' ? api().PlayEpisode(it.id, from) : api().PlayRecording(it.id, from));130 if (token !== state.tuneToken) return;131 state.recOffset = pb.offset || 0;132 await play(pb, token);133 } catch (e) {134 if (token !== state.tuneToken) return;135 showNoSignal({ number: it.kind === 'episode' ? 'POD' : 'REC', callSign: it.title, network: '' }, String(e && e.message ? e.message : e));136 } finally {137 if (token === state.tuneToken) setTimeout(() => stage.classList.remove('tuning'), 200);138 }139}140141// ---------- recording progress ----------142let progressTimer = 0;143function saveRecordingProgress() {144 const it = state.recording;145 if (!it || !it.id) return;146 const pos = (state.recOffset || 0) + video.currentTime;147 if (pos < 1) return;148 it.position = pos;149 const saved = it.kind === 'episode'150 ? api().EpisodeProgress(it.id, pos, it.duration || 0, !!it.duration && pos > it.duration - 60)151 : api().SaveProgress(it.id, pos, it.duration || 0);152 saved.catch((e) => log('warn', `progress: ${e}`));153}154155video.addEventListener('timeupdate', () => {156 if ($('#banner').classList.contains('show')) showTimeshift();157 if (osd.open) renderTimeline();158 if (state.recording && Date.now() - progressTimer > 15000) {159 progressTimer = Date.now();160 saveRecordingProgress();161 }162});163video.addEventListener('pause', () => { if (state.recording) saveRecordingProgress(); });164video.addEventListener('play', () => updatePlaybackState());165video.addEventListener('pause', () => updatePlaybackState());166// The controls stay while paused, and count down again once playing.167for (const ev of ['play', 'pause', 'playing', 'emptied']) {168 video.addEventListener(ev, () => {169 if (!osd.open) return;170 renderOSD();171 osdTouch();172 });173}174video.addEventListener('playing', () => {175 if (!state.recording && state.liveBaseline == null) state.liveBaseline = Math.max(0, liveEdge() - video.currentTime);176 applyCaptions();177 applyAudio();178});179if (video.textTracks) {180 video.textTracks.addEventListener('addtrack', applyCaptions);181 video.textTracks.addEventListener('removetrack', applyCaptions);182 video.textTracks.addEventListener('change', keepCaptions);183}184if (video.audioTracks) video.audioTracks.addEventListener('addtrack', applyAudio);185186// ---------- playback state ----------187// A small badge at the picture's bottom right says what playback is doing,188// on the TV view. It stays only while there's something to do about it:189// paused (with how far behind live, or where in a recording), and190// buffering (once a stream has played, after 300 ms of waiting with the191// picture stopped). The rest192// flashes and fades: a skip (the amount, adding up over quick presses) and193// then where it left the picture; how far behind live on resuming, or on194// the info key; "Live" on going back to live. Playing behind live, it's195// gone: the banner says how far. It keeps above the banner and the weather196// crawl, as the captions do.197const PS_ICONS = {198 pause: '<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="3" y="2" width="3.6" height="12" rx="1"/><rect x="9.4" y="2" width="3.6" height="12" rx="1"/></svg>',199 back: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8.2 3 1.5 8l6.7 5zM15 3 8.3 8 15 13z"/></svg>',200 ahead: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M7.8 3l6.7 5-6.7 5zM1 3l6.7 5L1 13z"/></svg>',201 wait: '<i class="ps-spin"></i>',202 live: '<i class="ps-dot"></i>',203};204const PS_SKIP_MS = 1500; // a skip shows this long after the last press205const PS_PLACE_MS = 2500; // then where it left the picture, this long206const PS_WAIT_MS = 300; // shorter waits for data go unmarked207const ps = {208 el: $('#pstate'), skip: 0, dir: 0, skipUntil: 0, skipTimer: 0, placeUntil: 0, placeLive: false, placeTimer: 0,209 played: false, paused: false, waiting: false, waitAt: 0, waitTimer: 0, tick: 0, shown: '',210};211212// noteSkip shows a skip of delta seconds (what playback moved, which is213// less than asked at either end of what can be rewound), then where it214// left the picture.215function noteSkip(delta, asked) {216 const now = Date.now();217 ps.skip = now < ps.skipUntil && Math.sign(asked) === ps.dir ? ps.skip + delta : delta;218 ps.dir = Math.sign(asked);219 ps.skipUntil = now + PS_SKIP_MS;220 clearTimeout(ps.skipTimer);221 ps.skipTimer = setTimeout(renderPlayState, PS_SKIP_MS + 20);222 flashPlace(ps.dir > 0, PS_SKIP_MS);223}224225// flashPlace shows for a moment how far behind live the picture is, or226// where in a recording; at live, "Live" when live is set.227function flashPlace(live = false, after = 0) {228 ps.placeUntil = Date.now() + after + PS_PLACE_MS;229 ps.placeLive = live;230 clearTimeout(ps.placeTimer);231 ps.placeTimer = setTimeout(renderPlayState, after + PS_PLACE_MS + 20);232 renderPlayState();233}234235function skipLabel() {236 const n = Math.round(ps.skip);237 if (!n) return ps.dir > 0 && !state.recording ? 'Live' : ps.dir > 0 ? 'End' : 'Start';238 const a = Math.abs(n);239 return `${n < 0 ? '-' : '+'}${a < 100 ? `${a} s` : mmss(a)}`;240}241242// placeText is where the picture is: how far behind live (when it's more243// than the moment a pause or the stream's own delay accounts for), or the244// position in a recording.245function placeText(paused) {246 if (state.recording) return `${mmss((state.recOffset || 0) + video.currentTime)} / ${mmss(state.recording.duration || 0)}`;247 const behind = behindLive();248 return behind > (paused ? 2 : 4) ? `-${mmss(behind)}` : '';249}250251function renderPlayState() {252 let kind = '';253 let text = '';254 const playing = !!(video.src || state.hls) && !video.hidden && $('#nosignal').hidden && !stage.classList.contains('tuning');255 if (playing) {256 const now = Date.now();257 if (now < ps.skipUntil) {258 kind = ps.dir < 0 ? 'back' : 'ahead';259 text = skipLabel();260 } else if (video.paused) {261 kind = 'pause';262 text = placeText(true);263 } else if (ps.waiting) {264 kind = 'wait';265 } else if (now < ps.placeUntil) {266 text = placeText(false);267 if (text) kind = 'place';268 else if (ps.placeLive && !state.recording) { kind = 'live'; text = 'Live'; }269 }270 }271 // While paused the live edge moves on with no timeupdate to say so.272 if (kind === 'pause' && !state.recording && !ps.tick) ps.tick = setInterval(renderPlayState, 1000);273 if (kind !== 'pause' && ps.tick) { clearInterval(ps.tick); ps.tick = 0; }274 const key = `${kind}|${text}`;275 if (key === ps.shown) return;276 ps.shown = key;277 if (!kind) {278 ps.el.classList.remove('show'); // fades out as it was279 return;280 }281 ps.el.className = `show ps-${kind}`;282 ps.el.innerHTML = `${PS_ICONS[kind] || ''}${text ? `<span>${esc(text)}</span>` : ''}`;283}284285// placePlayState puts the badge at the picture's bottom right (inside any286// bars around it), or above the banner or the crawl: captionSizes'287// rule for the captions.288function placePlayState() {289 const w = stage.clientWidth;290 const h = stage.clientHeight;291 if (!w || !h) return;292 const ar = video.videoWidth && video.videoHeight ? video.videoWidth / video.videoHeight : 16 / 9;293 const pw = Math.min(w, h * ar);294 const ph = pw / ar;295 let bottom = (h - ph) / 2 + ph * 0.045;296 const banner = $('#banner');297 if (state.view === 'tv' && banner.classList.contains('show')) bottom = Math.max(bottom, h - banner.offsetTop + 14);298 const crawl = $('#crawl');299 if (crawl && document.body.classList.contains('has-crawl')) bottom = Math.max(bottom, crawl.offsetHeight + 14);300 ps.el.style.bottom = `${Math.round(bottom)}px`;301 ps.el.style.right = `${Math.round((w - pw) / 2 + pw * 0.025)}px`;302}303304function startWaiting() {305 if (!ps.played || ps.waitTimer || ps.waiting) return;306 const at = video.currentTime;307 ps.waitTimer = setTimeout(() => {308 ps.waitTimer = 0;309 // A stall the picture plays through isn't a wait: a player with all it310 // needs stops fetching for a while, which WebKit calls stalled.311 if (video.paused || video.currentTime > at + 0.1) return;312 ps.waiting = true;313 ps.waitAt = video.currentTime;314 renderPlayState();315 }, PS_WAIT_MS);316}317318function stopWaiting() {319 clearTimeout(ps.waitTimer);320 ps.waitTimer = 0;321 if (ps.waiting) ps.waiting = false;322 renderPlayState();323}324325video.addEventListener('waiting', startWaiting);326video.addEventListener('stalled', startWaiting);327for (const ev of ['canplay', 'pause', 'play', 'seeked']) video.addEventListener(ev, stopWaiting);328video.addEventListener('playing', () => { ps.played = true; stopWaiting(); });329// Resuming a paused stream says where it resumes.330video.addEventListener('pause', () => { if (ps.played) ps.paused = true; });331video.addEventListener('play', () => {332 if (!ps.paused) return;333 ps.paused = false;334 flashPlace();335});336// A new stream: its first wait for data is the tune, which has its static.337video.addEventListener('emptied', () => {338 ps.played = false;339 ps.paused = false;340 ps.skipUntil = 0;341 ps.placeUntil = 0;342 stopWaiting();343});344// Playing again ends a wait, whatever the events said.345video.addEventListener('timeupdate', () => {346 if (ps.waiting && video.currentTime > ps.waitAt + 0.25) stopWaiting();347 else renderPlayState();348});349video.addEventListener('resize', placePlayState);350if (window.ResizeObserver) new ResizeObserver(placePlayState).observe(stage);351new MutationObserver(() => { placePlayState(); renderPlayState(); }).observe($('#banner'), { attributes: true, attributeFilter: ['class'] });352new MutationObserver(() => { placePlayState(); renderPlayState(); }).observe(document.body, { attributes: true, attributeFilter: ['class'] });353new MutationObserver(renderPlayState).observe(stage, { attributes: true, attributeFilter: ['class'] });354new MutationObserver(renderPlayState).observe($('#nosignal'), { attributes: true, attributeFilter: ['hidden'] });355356// ---------- media session ----------357// What is on, for the system's media controls and keys: Now Playing on a358// Mac, MPRIS on Linux (which WebKitGTK provides).359const mediaSession = navigator.mediaSession;360361function wireMediaSession() {362 if (!mediaSession) return;363 const on = (action, fn) => {364 try { mediaSession.setActionHandler(action, fn); } catch { /* not offered here */ }365 };366 on('play', () => { if (video.paused) togglePause(); });367 on('pause', () => { if (!video.paused) togglePause(); });368 on('nexttrack', () => step(1));369 on('previoustrack', () => step(-1));370 on('seekforward', () => skip(30));371 on('seekbackward', () => skip(-10));372}373374let mediaShown = '';375function updateMediaSession() {376 if (!mediaSession || !window.MediaMetadata) return;377 const ch = state.current;378 const it = state.recording;379 let m = null;380 if (it) {381 m = { title: it.title, artist: 'Recording', album: it.subtitle || 'Airwaves', art: imageURL(it.image) };382 } else if (ch && ch.weather) {383 m = { title: 'Local Forecast', artist: `${ch.number} ${ch.name}`, album: 'Airwaves', art: ch.logo };384 } else if (ch) {385 const p = airingAt(ch, Date.now());386 const name = ch.network || displayCall(ch);387 m = { title: p ? p.title : name, artist: `${ch.number} ${name}`, album: (p && p.episodeTitle) || 'Airwaves', art: imageURL(p && p.image) || ch.logo };388 }389 const key = JSON.stringify(m);390 if (key === mediaShown) return;391 mediaShown = key;392 mediaSession.metadata = m && new window.MediaMetadata({ title: m.title, artist: m.artist, album: m.album, artwork: m.art ? [{ src: m.art }] : [] });393 updatePlaybackState();394}395396function updatePlaybackState() {397 if (!mediaSession) return;398 const wx = state.current && state.current.weather && !state.recording;399 mediaSession.playbackState = !state.current && !state.recording ? 'none' : wx || !video.paused ? 'playing' : 'paused';400}