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
This commit is contained in:
2026-11-13 18:40:49 +00:00
parent 47e093e313
commit 5ef01593d7
9 changed files with 201 additions and 86 deletions

View File

@@ -46,24 +46,41 @@ if(worker) (async()=>{
try{ for(const k of await (await caches.open(KEPT)).keys()) kept.add(new URL(k.url).pathname); }catch{}
if(kept.size) syncKeepButtons();
})();
/// The open item's files say where each is, and its button what it does next (#457): drawn again
/// in place, keeping the reader where it was.
function syncKeepButtons(){
document.querySelectorAll<HTMLButtonElement>('[data-a="offline"]').forEach(b=>{
const k=kept.has(b.dataset.url!);
b.setAttribute('aria-pressed',String(k));
b.setAttribute('aria-busy',String(keeping.has(b.dataset.url!)));
b.innerHTML=k?ICON.kept:ICON.keep;
const label=k?'Kept on this device to play offline. Remove it':'Keep on this device to play offline';
b.title=label; b.setAttribute('aria-label',label);
}); // A row says whether its item is kept; redrawn only where that has changed.
const e=cur();
if(e && $('#files .encbox, #detail .encbox')){
const box=$('#detail'), top=box?.scrollTop??0;
showDetail(e);
if(box) box.scrollTop=top;
}
// A row says whether its item is kept; redrawn only where that has changed.
for(const e of S.entries)
if(e.enclosures.some(isKept)!==!!$(`#eps .ep[data-key="${CSS.escape(entryKey(e))}"] .kept`)) swapRow(e);
}
/// The button for a file on the server that plays; none without a worker, or an app, to play it
/// offline.
function keepBtn(x: Enclosure){
if(!worker&&!ipxHost) return '';
const k=isKept(x), label=k?'Kept on this device to play offline. Remove it':'Keep on this device to play offline';
return `<button class="btn ico" data-a="offline" data-enc="${x.id}" data-url="${keepUrl(x)}" aria-pressed="${k}" aria-busy="${keeping.has(keepUrl(x))}" title="${label}" aria-label="${label}">${k?ICON.kept:ICON.keep}</button>`;
/// Whether this device can keep a file to play offline: a worker to keep it, or the app.
const canKeep=()=>!!worker||!!ipxHost;
/* Files asked for on this device before iPX had them (#457): iPX fetches each, and its copy here
follows once iPX says it is done, or, when the page was closed meanwhile, the next time a list
shows it with its file. Kept in localStorage, so a closed app still remembers. */
const wanted=new Map<number,string>();
try{ for(const [id,name] of JSON.parse(localStorage.getItem('ipx.wanted')||'[]') as [number,string][]) wanted.set(id,name); }catch{}
function saveWanted(){ try{ localStorage.setItem('ipx.wanted',JSON.stringify([...wanted])); }catch{} }
function want(id: number, name: string){ wanted.set(id,name); saveWanted(); }
function unwant(id: number){ if(wanted.delete(id)) saveWanted(); }
/// iPX has the file now: this device's copy follows. The copy without dead air comes later, if it
/// does, so the file itself is what is kept.
function followWanted(x: {id: number, path?: string|null, deadair?: number|null}){
const name=wanted.get(x.id);
if(name==null || !x.path) return;
unwant(x.id);
if(!isKept(x)) void toggleKept(x as Enclosure, name);
}
/// Those of `entries` iPX fetched while this page was not looking.
function followWantedIn(entries: Entry[]){
if(!wanted.size) return;
for(const e of entries) for(const x of e.enclosures) if(x.path && wanted.has(x.id)) followWanted(x);
}
async function toggleKept(x: Enclosure, name: string){
const url=keepUrl(x);