/* ---------------- player ---------------- */ const audio=$('#audio'); const player: {guid: string|null, feed: string|null, entry: any, enc?: number, moved?: boolean, saveAt: number, marked: boolean}={guid:null,feed:null,entry:null,saveAt:0,marked:false}; // 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,enc=e.enclosures.find(isPlayable)){ if(!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. const resuming = player.guid===e.guid && player.enc===enc.id; 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'); audio.src=`/media/${enc.id}`; 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=e.title||'(untitled)'; 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; } audio.play().catch(err=>toast('Playback failed: '+err.message,true)); renderEntries(); } function mediaSession(e,f){ if(!('mediaSession' in navigator)) return; navigator.mediaSession.metadata=new MediaMetadata({ title:e.title||'', artist:f?(f.title||f.id):'', album:f?(f.title||''):'', artwork:(e.image||(f&&f.image))?[{src:e.image||f.image,sizes:'512x512'}]:[], }); const h={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?.( `/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`, 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','timeupdate']) audio.addEventListener(ev,syncListening); window.addEventListener('beforeunload',savePos); $('#pplay').onclick=()=>audio.paused?audio.play():audio.pause(); $('#pback').onclick=()=>audio.currentTime-=15; $('#pfwd').onclick=()=>audio.currentTime+=30; $('#seek').oninput=e=>{const d=audio.duration;if(d)audio.currentTime=d*e.target.value/1000}; $('#rate').onchange=e=>{audio.playbackRate=+e.target.value;localStorage.setItem('ipx.rate',e.target.value)}; $('#vol').oninput=e=>{audio.volume=e.target.value/100;localStorage.setItem('ipx.vol',e.target.value)}; $('#pclose').onclick=()=>{savePos();audio.pause();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={a:':all',d:':directory',p:':popular',l:':listening'}; let gAt=0; function stepEntry(by){ 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); $(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`)?.scrollIntoView({block:'nearest'}); } function stepFeed(by){ 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={ 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){ // 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=>`${esc(s)}`, g=c=>k('g')+' '+k(c); const rows=[ ['Go to'], [g('a'),'All Subscriptions'],[g('d'),'Directory'],[g('p'),'Popular'], [g('l'),'Currently Listening'],[g('s'),'Settings'], [k('Shift')+' '+k('J'),'Next feed'],[k('Shift')+' '+k('K'),'Previous feed'], [k('/'),'Search items'],[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}
`); }