An item's link and an enclosure's URL are the publisher's, stored as written, and the page put them in href and window.open through esc(), which escapes characters, not schemes: a javascript: or data: link survived as Open the original, View in a new tab (for a file not on disk), the Directory's item links and the v key. Show notes already lose those schemes to ammonia; these four sinks were the exception. webUrl() passes http(s) only, and each sink goes through it. A link that fails it shows no link, as an item with none does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
18 KiB
TypeScript
303 lines
18 KiB
TypeScript
/* ---------------- player ---------------- */
|
|
const audio=$<HTMLAudioElement>('#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<void>|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<void>((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('Unable to play the DeadAir version. Try again, or turn DeadAir off for this show.',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(()=>{});
|
|
}
|
|
|
|
/// What a failed play() means to whoever pressed play; the browser's own words go to the log (#304).
|
|
function playFailed(err: Error){
|
|
// A newer load took its place: nothing failed.
|
|
if(err.name==='AbortError') return;
|
|
report('player_error',{msg:`play(): ${err.name}: ${err.message}`,...playing()});
|
|
toast(err.name==='NotAllowedError' ? 'The browser held playback back. Press play to start it.'
|
|
: 'Unable to play this file. Try again, or download it again.', true);
|
|
}
|
|
/// 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. Download it first, with its button on the item.', 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<void>|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(playFailed);
|
|
if(wait) wait.then(go,playFailed); 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: Record<string,()=>void>={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) $<HTMLInputElement>('#seek').value=String(Math.round(audio.currentTime/d*1000));
|
|
// Its value is thousandths, which a screen reader would read out as they are (#301).
|
|
$('#seek').setAttribute('aria-valuetext',`${clock(audio.currentTime)} of ${clock(d)}`);
|
|
// 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();$<HTMLButtonElement>('#pplay').innerHTML=ICON.play});
|
|
// body.playing is what sets the EQ bars moving.
|
|
audio.addEventListener('play',()=>{ $<HTMLButtonElement>('#pplay').innerHTML=ICON.pause; document.body.classList.add('playing'); });
|
|
audio.addEventListener('pause',()=>{ $<HTMLButtonElement>('#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<HTMLElement>('.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($<HTMLButtonElement>('#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()}); });
|
|
$<HTMLButtonElement>('#pplay').onclick=()=>audio.paused?audio.play():audio.pause();
|
|
$<HTMLButtonElement>('#pback').onclick=()=>audio.currentTime-=15;
|
|
$<HTMLButtonElement>('#pfwd').onclick=()=>audio.currentTime+=30;
|
|
$<HTMLInputElement>('#seek').oninput=(e: Event)=>{const d=audio.duration;if(d)audio.currentTime=d*Number((e.target as HTMLInputElement).value)/1000};
|
|
$<HTMLSelectElement>('#rate').onchange=(e: Event)=>{audio.playbackRate=+(e.target as HTMLSelectElement).value;localStorage.setItem('ipx.rate',(e.target as HTMLSelectElement).value)};
|
|
$<HTMLInputElement>('#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=$<HTMLButtonElement>('#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';
|
|
}
|
|
$<HTMLButtonElement>('#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;
|
|
}
|
|
$<HTMLButtonElement>('#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){$<HTMLSelectElement>('#rate').value=r;audio.playbackRate=+r}
|
|
if(v){$<HTMLInputElement>('#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();$<HTMLInputElement>('#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<string,string>={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: Record<string,()=>void>={
|
|
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:()=>$<HTMLButtonElement>('#tbPlay').click(), m:()=>$<HTMLButtonElement>('#tbRead').click(), s:()=>$<HTMLButtonElement>('#tbFlag').click(),
|
|
v:()=>{ const u=webUrl(cur()?.link); if(u) window.open(u,'_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)=>`<kbd>${esc(s)}</kbd>`, 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(`<button class="iconbtn cardx" onclick="closeModal()" title="Close" aria-label="Close">${ICON.close}</button><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>`);
|
|
}
|
|
|