Only the player bar's button changed; the files pane's, the row's and the toolbar's kept showing play while it played. play() now pauses when asked to play what is already playing, which makes each of them a toggle, and syncPlayButtons() repaints them on play, pause and ended and whenever the list or reader is drawn. Closes #34. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
11 KiB
TypeScript
199 lines
11 KiB
TypeScript
/* ---------------- 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;
|
|
// Every play button is a pause button for what is playing, as the player bar's is.
|
|
if(resuming && !audio.paused){ audio.pause(); return; }
|
|
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','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,enc?)=>!audio.paused&&player.guid===guid&&(enc==null||player.enc===enc);
|
|
const paint=(b,now,idle)=>{
|
|
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);
|
|
$('#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=>`<kbd>${esc(s)}</kbd>`, 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(`<h3>Keyboard shortcuts</h3><table class="keys">${rows.map(([a,b])=>b===undefined
|
|
?`<tr><th colspan="2">${a}</th></tr>`:`<tr><td>${a}</td><td>${b}</td></tr>`).join('')}</table>
|
|
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button></div>`);
|
|
}
|
|
|