/* ---------------- player ---------------- */ const audio=$('#audio'); const player: {guid: string|null, feed: string|null, entry: Entry|null, enc?: number, src?: string, moved?: boolean, saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false}; /// Where a file plays from: DeadAir's playlist when the server says this person plays it /// shortened (#171, #181), whose times the entry is already given in, else the file itself. const mediaSrc=(enc: {id: number, deadair?: number|null})=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`; /// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it /// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails /// on the playlist's MP3 ranges with "stream parsing failed", and so plays nothing. let hls: any=null; function dropHls(){ if(hls){ hls.destroy(); hls=null; } } /// Puts a file in the player: null when it is there at once, else a promise to wait on before /// playing, while hls.js loads. Safari always takes the first way, where play() has to run within /// the tap itself or it is refused. function setSrc(url: string): Promise|null{ dropHls(); const native=navigator.vendor.startsWith('Apple')&&!!audio.canPlayType('application/vnd.apple.mpegurl'); // Safari fetches the playlist itself, with no header to carry the tap's trace: in the address // instead (#192). Only the playlist, which is never cached. if(!url.endsWith('.m3u8')){ audio.src=url; return null; } if(native){ audio.src=`${url}?traceparent=${traceparent()}`; return null; } const w=window as any; const ready=w.Hls?Promise.resolve():new Promise((ok,fail)=>{ const s=document.createElement('script'); s.src='__HLS_JS__'; s.onload=()=>ok(); s.onerror=()=>fail(new Error('the DeadAir player did not load')); document.head.append(s); }); return ready.then(()=>{ // Its playlist and audio requests join the trace of the tap that played them (#192). hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())}); hls.on(w.Hls.Events.ERROR,(_: string,d: {fatal: boolean, type: string, details: string})=>{ if(d.fatal){ toast('Playback failed: '+d.details,true); report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } }); hls.loadSource(url); hls.attachMedia(audio); }); } // Marked read when an item has actually been listened to -- at the end, or past 90%. // NOT on play: doing that made the item vanish from the Unread list the instant // you pressed play, which looks exactly like it went missing. function markPlayed(){ if(!player.guid||player.marked) return; player.marked=true; const e=player.entry; if(!e||e.read) return; setRead(e,true).catch(()=>{}); } /// Plays one of the item's files in the player bar: the one asked for, or its first playable one. function play(e: Entry,enc=e.enclosures.find(isPlayable)){ if(!enc||!isPlayable(enc)){ toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true); return; } // The same file carries on where it was; another of the item's files starts from its top. So // does the other version of the same file, from the place the entry gives in its clock. const resuming = player.guid===e.guid && player.enc===enc.id && player.src===mediaSrc(enc); // Every play button is a pause button for what is playing, as the player bar's is. if(resuming && !audio.paused){ audio.pause(); return; } let wait: Promise|null=null; if(!resuming){ player.guid=e.guid; player.feed=e.feed_id; player.entry=e; player.enc=enc.id; player.moved=false; document.body.classList.toggle('has-video', kindOf(enc)==='video'); wait=setSrc(player.src=mediaSrc(enc)); audio.currentTime=0; if(e.position>5) audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true}); // Initials, if it comes to that, are the feed's: the episode's read as "SE" beside the feed's art. $('#partwrap').innerHTML=artHTML(e.image||feedArt(e.feed_id),feedName(e.feed_id)); $('#ptitle').textContent=entryName(e).text; const f=S.feeds.find(x=>x.id===e.feed_id); $('#pfeed').textContent=f?(f.title||f.id):''; $('#player').classList.add('on'); mediaSession(e,f); player.marked=false; paintDead(); } const go=()=>audio.play().catch((err: Error)=>toast('Playback failed: '+err.message,true)); if(wait) wait.then(go,err=>toast('Playback failed: '+err.message,true)); else go(); renderEntries(); } function mediaSession(e: Entry,f: Feed|undefined){ if(!('mediaSession' in navigator)) return; navigator.mediaSession.metadata=new MediaMetadata({ title:entryName(e).text, artist:f?(f.title||f.id):'', album:f?(f.title||''):'', artwork:(e.image||(f&&f.image))?[{src:artSrc((e.image||f?.image)!),sizes:'512x512'}]:[], }); const h: Recordvoid>={play:()=>audio.play(),pause:()=>audio.pause(), seekbackward:()=>audio.currentTime-=15,seekforward:()=>audio.currentTime+=30}; for(const k in h){ try{navigator.mediaSession.setActionHandler(k as MediaSessionAction,h[k])}catch{} } } audio.addEventListener('timeupdate',()=>{ const d=audio.duration||player.entry?.duration||0; $('#pcur').textContent=clock(audio.currentTime); $('#pdur').textContent=clock(d); if(d) $('#seek').value=String(Math.round(audio.currentTime/d*1000)); // Only playing counts as moving: the seek to where you left off happens paused, and saving // that would write back whatever the list said, however old. if(!audio.paused) player.moved=true; // Persist roughly every 10s so a reload resumes where you were. Either way: a jump back used // to wait for the next pause to be saved. if(player.guid && Math.abs(audio.currentTime-player.saveAt)>10){ savePos(); } if(d && audio.currentTime/d >= 0.9) markPlayed(); }); function savePos(){ // Before the file has loaded, currentTime is 0 rather than where you are: saving it then -- // a failed load, or a pause before the seek to where you left off -- wiped the position. // Nor from a player nobody has played since it last saved: one left paused in another tab // saved its older place as that tab reloaded, over where you had got to since. if(!player.guid||!audio.readyState||!player.moved) return; player.moved=false; player.saveAt=audio.currentTime; if(player.entry) player.entry.position=Math.floor(audio.currentTime); // The measured length stands in for one the feed left out: without it Currently Listening // cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream. const duration=isFinite(audio.duration)?Math.floor(audio.duration):null; navigator.sendBeacon?.( // A beacon sends no headers, so the trace goes in the address (#192). `/api/entries/${encodeURIComponent(player.feed!)}/${encodeURIComponent(player.guid!)}/position?traceparent=${traceparent()}`, new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'})); } audio.addEventListener('pause',savePos); audio.addEventListener('ended',()=>{savePos();markPlayed();$('#pplay').innerHTML=ICON.play}); // body.playing is what sets the EQ bars moving. audio.addEventListener('play',()=>{ $('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); }); audio.addEventListener('pause',()=>{ $('#pplay').innerHTML=ICON.play; document.body.classList.remove('playing'); }); for(const ev of ['play','pause','ended']) audio.addEventListener(ev,syncPlayButtons); /// Every play button for what is playing shows pause, like the player bar's: a row's, the files /// pane's, the toolbar's. Only the bar's used to change, so the others said play while it played. function syncPlayButtons(){ const on=(guid: string|null,enc?: number)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc); const paint=(b: HTMLElement,now: boolean,idle: string)=>{ const label=now?'Pause':idle; if(b.title===label) return; b.title=label; b.setAttribute('aria-label',label); b.innerHTML=now?ICON.pause:ICON.play; }; for(const b of $$('#eps .ep [data-a=play]')) paint(b,on(b.closest('.ep')!.dataset.guid!),'Play'); for(const b of $$('#files [data-a=play][data-enc], #detail [data-a=play][data-enc]')) paint(b,on(S.sel,Number(b.dataset.enc)),'Play'); const e=cur(); paint($('#tbPlay'),!!e&&on(e.guid),'Play the selected item'); } for(const ev of ['play','pause','timeupdate']) audio.addEventListener(ev,syncListening); window.addEventListener('beforeunload',savePos); /// For the server's log (#190): how long play takes to start, how long it stalls for, and what /// the browser says when it cannot play a file at all. let asked=0, waited=0; const playing=()=>({feed:player.feed,enclosure:player.enc,deadair:!!player.src?.endsWith('.m3u8')}); audio.addEventListener('play',()=>{ asked=performance.now(); }); audio.addEventListener('waiting',()=>{ if(!asked) waited=performance.now(); }); audio.addEventListener('playing',()=>{ const now=performance.now(); if(asked) report('play_start',{ms:Math.round(now-asked),...playing()}); else if(waited && now-waited>=500) report('stall',{ms:Math.round(now-waited),...playing()}); asked=0; waited=0; }); audio.addEventListener('error',()=>{ if(audio.error) report('player_error',{msg:`media error ${audio.error.code}: ${audio.error.message}`,...playing()}); }); $('#pplay').onclick=()=>audio.paused?audio.play():audio.pause(); $('#pback').onclick=()=>audio.currentTime-=15; $('#pfwd').onclick=()=>audio.currentTime+=30; $('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000}; $('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)}; $('#vol').oninput=(e: Event)=>{audio.volume=Number((e.target as HTMLInputElement).value)/100;localStorage.setItem('ipx.vol',(e.target as HTMLInputElement).value)}; /// DeadAir's switch beside the speed: on or off for the show that is playing. The state is in the /// icon's shape (the silence still in it, or closed up) and aria-pressed, never a colour. function paintDead(){ const b=$('#pdead'), f=S.feeds.find(x=>x.id===player.feed); b.hidden=!f||document.body.classList.contains('has-video'); if(!f||b.hidden) return; b.setAttribute('aria-pressed',String(!!f.deadair)); b.innerHTML=tile(f.deadair?'deadair-on':'deadair-off'); b.title=f.deadair?'DeadAir is cutting the dead air from this show':'DeadAir: cut the dead air from this show'; } $('#pdead').onclick=async()=>{ const f=S.feeds.find(x=>x.id===player.feed); if(!f) return; const on=!f.deadair; try{ await api(`/api/feeds/${encodeURIComponent(f.id)}`,{method:'PATCH',body:JSON.stringify({deadair:on})}); } catch(err){ toast(why(err),true); return; } f.deadair=on; paintDead(); const switched=await switchVersion(); toast(!on?'DeadAir is off for this show':switched?'DeadAir is cutting the dead air from this show' :'DeadAir is on for this show. Episodes lose their dead air a few minutes after they download.'); renderEntries(); }; /// Carries on at the same place in the other version of what is playing, once DeadAir has been /// turned on or off for its show. True if it changed version: a copy not made yet plays next time. async function switchVersion(){ if(!player.guid) return false; const [feed,guid]=[player.feed!,player.guid!].map(s=>encodeURIComponent(s)); // Saved and waited for, not beaconed, so the entry asked for next has this place in it. if(audio.readyState){ await api(`/api/entries/${feed}/${guid}/position`,{method:'POST',body:JSON.stringify({ secs:Math.floor(audio.currentTime),duration:isFinite(audio.duration)?Math.floor(audio.duration):null})}).catch(()=>{}); player.moved=false; } const e: Entry|null=await api(`/api/entries/${feed}/${guid}`).catch((): null=>null); const enc=e&&e.enclosures.find(x=>x.id===player.enc); if(!enc||mediaSrc(enc)===player.src) return false; const was=!audio.paused; player.entry=e; player.src=mediaSrc(enc); player.saveAt=e.position; const wait=setSrc(player.src); audio.addEventListener('loadedmetadata',()=>{audio.currentTime=e.position},{once:true}); if(was) (wait||Promise.resolve()).then(()=>audio.play()).catch(()=>{}); return true; } $('#pclose').onclick=()=>{savePos();audio.pause();dropHls();audio.removeAttribute('src');player.guid=null;$('#player').classList.remove('on');document.body.classList.remove('has-video');renderEntries(); // Called here, not left to the pause event: closing a player already paused fires none. syncListening()}; (function restore(){ const r=localStorage.getItem('ipx.rate'), v=localStorage.getItem('ipx.vol'); if(r){$('#rate').value=r;audio.playbackRate=+r} if(v){$('#vol').value=v;audio.volume=Number(v)/100} })(); document.addEventListener('keydown',ev=>{ // Escape leaves a dialog even from inside one of its boxes. It used to sit below the check // that follows, so Add feed, which opens with the cursor in its URL box, ignored it. if(ev.key==='Escape'){closeModal();nav(false);return} // The rest are single keys that would otherwise eat what you type. if(/^(INPUT|TEXTAREA|SELECT)$/.test((ev.target as Element).tagName)) return; if(ev.key===' '&&player.guid){ev.preventDefault();audio.paused?audio.play():audio.pause()} else if(ev.key==='ArrowLeft'&&player.guid){audio.currentTime-=15} else if(ev.key==='ArrowRight'&&player.guid){audio.currentTime+=30} else if(ev.key==='/'){ev.preventDefault();$('#epSearch')?.focus()} else if(!ev.ctrlKey&&!ev.metaKey&&!ev.altKey&&!$('#modal').classList.contains('on')) typed(ev); }); // Feedly's keys, vim's j and k among them: a letter to move through items or feeds, g and a // letter to go somewhere, ? to list them. None fire with Ctrl, Alt or Cmd held, so the browser's // own shortcuts still work, or while a dialog is open. const GO: Record={a:':all',d:':directory',l:':listening'}; let gAt=0; function stepEntry(by: number){ if(VIEWS[S.feed ?? '']?.url||!S.entries.length) return; const i=S.entries.findIndex(x=>x.guid===S.sel); const e=S.entries[i<0?0:Math.min(S.entries.length-1,Math.max(0,i+by))]; selectEntry(e,true); $(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'}); } function stepFeed(by: number){ const rows=$$('#feedlist [data-id]'), i=rows.findIndex(r=>r.dataset.id===S.feed), id=rows[i+by]?.dataset.id; if(!id) return; selectFeed(id); $(`#feedlist [data-id="${CSS.escape(id)}"]`)?.scrollIntoView({block:'nearest'}); } const KEYS: Recordvoid>={ j:()=>stepEntry(1), n:()=>stepEntry(1), k:()=>stepEntry(-1), p:()=>stepEntry(-1), J:()=>stepFeed(1), K:()=>stepFeed(-1), // The toolbar's own buttons, so a key does exactly what the click does, and nothing while // they are disabled. o:()=>$('#tbPlay').click(), m:()=>$('#tbRead').click(), s:()=>$('#tbFlag').click(), v:()=>{ const e=cur(); if(e&&e.link) window.open(e.link,'_blank','noopener'); }, A:()=>$('#content .fhead [data-a="read"], #content .fhead [data-a="readall"]')?.click(), r:async()=>{ await loadFeeds(true); if(S.feed){ renderFeed(); loadEntries(); } }, '[':()=>matchMedia('(max-width:820px)').matches ? nav(!$('#sidebar').classList.contains('open')) : document.body.classList.toggle('nosb'), '?':()=>keysModal(), g:()=>{ gAt=Date.now(); }, }; function typed(ev: KeyboardEvent){ // The second key of a g pair counts only if it follows within a second and a half. const pair=Date.now()-gAt<1500; gAt=0; const fn=pair ? (GO[ev.key]&&(()=>selectFeed(GO[ev.key])))||(ev.key==='s'&&prefsModal) : KEYS[ev.key]; if(!fn) return; ev.preventDefault(); fn(); } /// What ? shows: every key, grouped as Feedly's own list is. function keysModal(){ const k=(s: string)=>`${esc(s)}`, g=(c: string)=>k('g')+' '+k(c); const rows=[ ['Go to'], [g('a'),'All Subscriptions'],[g('d'),'Directory'], [g('l'),'Currently Listening'],[g('s'),'Settings'], [k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'], [k('/'),'Search items, Currently Listening or the Directory'],[k('r'),'Refresh'],[k('['),'Show or hide the feed list'], ['Items'], [k('j')+' or '+k('n'),'Next item'],[k('k')+' or '+k('p'),'Previous item'], [k('Shift')+' '+k('A'),'Mark all read'], ['The selected item'], [k('o'),'Play it'],[k('m'),'Mark it read or unread'],[k('s'),'Pin it, or unpin it'], [k('v'),'Open the original in a new tab'], ['The player'], [k('Space'),'Play or pause'],[k('←')+' '+k('→'),'Back 15 seconds, forward 30'], ['Anywhere'], [k('?'),'This list'],[k('Esc'),'Close a dialog'], ]; openModal(`

Keyboard shortcuts

${rows.map(([a,b])=>b===undefined ?``:``).join('')}
${a}
${a}${b}
`); }