Files
ipx/web/src/events.ts
rays abee943bf5 One place to get an episode's file where you are (#457)
Getting an episode onto a phone took two buttons in two states: download
it to the server, wait, then keep it on the device, beside Save to this
computer, Share and Delete, five buttons that did not say where each
copy went. Now a file has one button that brings it to where you are.
On a phone or in the iOS app it ends on the device; when iPX has not got
the file yet, iPX fetches it and the device's copy follows once iPX says
it is done (remembered in localStorage, so a page closed meanwhile
catches up when a list next shows the file). On a computer it has iPX
fetch the file, and with iPX's copy there saves one. A line beside the
size says where the file is, and the rarer actions, two of them
destructive, are under a menu.

isPlayable is false for any file not on iPX, so the phone's button is
decided by playsKind, the same test without the path. download_done now
marks the file as on iPX in what the page holds, so the open file's box
changes at once rather than after the list is fetched again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
2026-11-14 03:42:16 +00:00

141 lines
8.1 KiB
TypeScript

/* ---------------- live events ---------------- */
let sse: EventSource;
/// The feed list and the open list, taken again from the server. The events keep both current,
/// and one missed is a count wrong until a reload: reading an item sends none to your other
/// pages, so one read on another device stayed unread here, and an iPhone stops the stream while
/// the app is in the background, when everything is missed (#168).
const catchUp=(()=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(()=>{
loadFeeds(true).catch(()=>{});
if(S.feed) loadEntries(false,true).catch(()=>{});
},300); }; })();
document.addEventListener('visibilitychange',()=>{ if(!document.hidden) catchUp(); });
let dropped=false;
function connect(){
sse=new EventSource('/api/events');
// Back after a drop: whatever was sent meanwhile went to nobody.
sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); checkVersion(); flushOutbox(); } };
const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; };
// Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219).
const refreshEntries=soon(()=>{ if(S.feed) loadEntries(false,true).catch(()=>{}); });
// Every scan's events reach everyone; only this person's feeds are theirs to show or refresh.
const mine=(id: string)=>S.feeds.some(f=>f.id===id);
// The lists a change made stale, fetched again behind, once a burst of rows has passed (#247).
const preloadSoon=soon(preloadLists,2000);
sse.onmessage=m=>{
let ev; try{ ev=JSON.parse(m.data) }catch{ return }
// Someone else's download is theirs: its file's name and its failure stay off this page (#294).
if((ev.ev==='progress'||ev.ev==='download_error')&&!mine(ev.feed)) return;
if(ev.ev==='progress'){
const pct=ev.total?ev.done/ev.total*100:0;
// Only the row actually downloading. Without the enclosure id this used to paint
// every pending bar at once, so adding a feed looked like it was fetching the lot.
const bar=document.querySelector<HTMLElement>(`.dlbar[data-bar="${ev.enclosure}"] i`);
if(bar){ bar.style.width=pct+'%'; bar.parentElement!.classList.add('live'); }
$('#count') && ($('#count').textContent=`downloading ${ev.file} — ${pct.toFixed(0)}%`);
}
else if(ev.ev==='download_done'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
// Said only for a file on screen, as one downloaded by hand is: the scheduled downloads of
// everyone's feeds used to announce themselves to everyone.
if(bar){ bar.classList.remove('live'); toast('Downloaded '+ev.path.split('/').pop()); }
// On iPX now, in what this page holds too, so the open file's box says so at once rather
// than after the list comes again; and, asked for on this device, its copy here follows (#457).
for(const e of S.entries) for(const x of e.enclosures) if(x.id===ev.enclosure){ x.path=ev.path; x.state='done'; }
if(wanted.has(ev.enclosure)) followWanted({id: ev.enclosure, path: ev.path});
else syncKeepButtons();
refreshEntries();
}
// A copy finished: the episode's "Cutting dead air…" becomes how much shorter it is.
else if(ev.ev==='deadair_done'){
if(!mine(ev.feed)) return;
if(ev.saved_s>0&&document.querySelector('.dapend')) toast(`DeadAir is done: ${shorter(ev.saved_s)}`);
refreshEntries();
if(S.sel) loadEntries(false,true).then(()=>{ const e=cur(); if(e) showDetail(e); }).catch(()=>{});
}
else if(ev.ev==='download_error'){
const bar=document.querySelector(`.dlbar[data-bar="${ev.enclosure}"]`);
if(bar) bar.classList.remove('live');
unwant(ev.enclosure);
// Which item, and what to do: the reason (ev.msg) is in the daemon's log (#304).
const e=S.entries.find(x=>x.enclosures.some(n=>n.id===ev.enclosure));
toast(`Unable to download ${e?`"${entryName(e).text}"`:'a file'}. Try again with its download button.`,true); refreshEntries();
}
// Jev picked items out for someone's Signals: theirs are fetched again, and the one open.
else if(ev.ev==='signal_hits'){
if(!S.signals.length) return;
refreshSignals();
if(signalOf(S.feed)) refreshEntries();
}
// A spinner on the feed's row while it is checked, in place of a toast per feed (issue #37).
else if(ev.ev==='feed_start') setScanning(ev.feed,true);
else if(ev.ev==='feed_skip') setScanning(ev.feed,false);
else if(ev.ev==='feed_done'){
setScanning(ev.feed,false);
if(!mine(ev.feed)) return;
if(ev.feed===S.feed||S.feed===':all'||S.feeds.some(f=>f.id===ev.feed&&f.group===S.feed)) refreshEntries();
}
// No toast: a scan of every feed raised one per failure, to everyone. The feed list's
// red ! marks the feed instead, and its page says why.
else if(ev.ev==='feed_error') setScanning(ev.feed,false);
// The server sends a feed's new row after anything changes it (counts, error, last check),
// only to those who subscribe; the page used to reload the whole list after each event.
else if(ev.ev==='feed_row'){ patchFeed(ev.row); forgetLists(ev.row.id); preloadSoon(); }
// Only a scan that checked something: the scheduler scans every minute, due or not, and
// every open page reloaded the whole list each time (#103).
// Rows came as feed_row events. ponytail: a feed an OPML drops stays in the list until the
// page reloads; reload the list here when a scan synced an OPML if that ever matters.
else if(ev.ev==='scan_done'){ scanning.clear(); paintScanning(); spinEnd(ev); if(ev.feeds) refreshEntries(); }
// The worker's job failed, so the scan someone is waiting on will send no scan_done.
else if(ev.ev==='error') spinEnd();
};
sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); };
}
/// A deploy of ipx-web drops the stream and the page kept running the script it loaded, in an
/// open tab or the home-screen app, until reloaded by hand (#388). Back after a drop, the page
/// asks for itself again: a different app.js named there is a new version.
const runningScript=document.querySelector('script[src^="/app.js"]')?.getAttribute('src');
async function checkVersion(){
if(!runningScript) return;
let page: string;
// Mid-deploy, / can be a 502 or the sign-in page: no script named, nothing to compare.
try{ const r=await fetch('/',{cache:'no-store'}); if(!r.ok) return; page=await r.text(); }catch{ return; }
const live=page.match(/\/app\.js\?v=[0-9a-f]+/)?.[0];
if(!live||live===runningScript) return;
if(reloadWouldLose()) offerReload();
else reloadKeepingPlace();
}
/// Something a reload throws away: an episode playing, a dialog open, a search or a field typed in.
function reloadWouldLose(){
const typing=document.activeElement instanceof HTMLTextAreaElement
|| (document.activeElement instanceof HTMLInputElement && document.activeElement.type!=='range'
&& document.activeElement.type!=='checkbox' && document.activeElement.value!=='');
return !audio.paused || !!$('#modal')?.classList.contains('on') || !!S.q || typing;
}
/// The feed and tab come back from localStorage; the item open is carried across in the session,
/// whole: read, it is off the Unread list the server sends after the reload.
function reloadKeepingPlace(){
try{ const e=cur(); if(e) sessionStorage.setItem('ipx.reopen',JSON.stringify(e)); }catch{}
location.reload();
}
/// The item open before a reload for a new version, once, for showList to open again.
function takeReopen(): Entry|null{
try{
const j=sessionStorage.getItem('ipx.reopen'); sessionStorage.removeItem('ipx.reopen');
return j ? JSON.parse(j) as Entry : null;
}catch{ return null; }
}
let offered=false;
function offerReload(){
if(offered) return;
offered=true;
const t=document.createElement('div'), b=document.createElement('button');
t.className='toast'; t.setAttribute('role','status'); t.textContent='A new version of iPX is ready. ';
b.type='button'; b.className='btn'; b.textContent='Reload';
b.onclick=reloadKeepingPlace;
t.appendChild(b);
$('#toasts')?.appendChild(t);
}
connect();
loadFeeds().then(preloadLists).catch(()=>{});