Episodes can be kept on the device to listen offline (#412)

Offline mode (#389) kept lists, text and pictures, but the worker let
every /media/ request pass, so no episode played without a connection.

A downloaded episode's files pane has a keep button beside Play. It
fetches the copy that person plays, the file or, when DeadAir cuts
something for them, the version without its dead air (/media/{id}/deadair,
the save button's), whole into a cache of its own, ipx-episodes, and
asks once for persistent storage. A kept episode plays as
/media/...?offline, online or not, and the worker answers that from the
cache with the byte ranges an audio element asks for (206, 416, 200);
not kept there, it goes to the server as before. Pressing it again takes
it off; signing out clears it with everything else. Only someone's
choice is kept, so there is no budget: a full device is the quota's
refusal, said in words. The button is data-a="offline", not "keep",
which the Directory's show page already uses for subscribing.

Places saved from DeadAir's version were read in its clock only if ipx
had handed out that file's playlist since it started (state.playing). A
kept copy plays without one, so the player now names the file in the
position it saves (deadair: <enclosure>) whenever it plays DeadAir's
clock, and ipx loads that file's plan when it has none, for that item's
file only. Checked the beacon is queued by the worker offline: it is.

Tests: playing a kept episode offline, its place queued and sent once
back, and taking it off (the offline show plays quiet.mp3, the only real
MP3 among the fixtures); the worker's ranges; which copy is kept and
played, in page-unit; the server reading a flagged place in DeadAir's
clock without a playlist, and ignoring another item's file. The files
pane's one-row test counts five buttons and checks a phone's width too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
This commit is contained in:
2026-11-07 15:31:04 +00:00
parent 07923d665b
commit 2c4d03668f
15 changed files with 284 additions and 7 deletions

View File

@@ -31,9 +31,68 @@ function savePictures(entries: Entry[]){
if(p.length) toWorker({pictures:p});
}
/* Episodes kept on this device (#412): the file, or the copy without its dead air for someone who
plays it shortened, fetched whole into a cache of their own and played from there whether
online or not (the worker answers `?offline` from it, byte ranges and all). Only what someone
asks to keep, so no budget: the browser's own quota says no when the device is full. */
const KEPT='ipx-episodes';
/// The addresses kept, read once at start: the page draws from this, without waiting on a cache.
const kept=new Set<string>();
/// The copy to keep of a file: the one it plays, and in the same clock its places are saved in.
const keepUrl=(x: {id: number, deadair?: number|null})=>(x.deadair??0)>0?`/media/${x.id}/deadair`:`/media/${x.id}`;
const isKept=(x: {id: number, deadair?: number|null})=>kept.has(keepUrl(x));
const keeping=new Set<string>();
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();
})();
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);
});
}
/// The button for a file on the server that plays; none without a worker to play it offline.
function keepBtn(x: Enclosure){
if(!worker) 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>`;
}
async function toggleKept(x: Enclosure, name: string){
const url=keepUrl(x);
if(keeping.has(url)) return;
const c=await caches.open(KEPT);
if(kept.has(url)){
await c.delete(url); kept.delete(url); syncKeepButtons();
toast('Removed from this device: '+name);
return;
}
keeping.add(url); syncKeepButtons();
toast('Keeping on this device: '+name);
try{
const r=await fetch(url);
if(!r.ok) throw new Error(await r.text()||`HTTP ${r.status}`);
// Asked once: kept files are not the browser's to clear when it runs short of room.
void navigator.storage?.persist?.();
await c.put(url,r);
kept.add(url);
toast('Kept on this device, to play offline: '+name);
}catch(err){
await c.delete(url).catch(()=>false);
toast(err instanceof DOMException&&err.name==='QuotaExceededError'
? 'There is not enough room on this device to keep it.'
: navigator.onLine ? 'Unable to keep it on this device. Try again.' : 'You are offline. Keep it once you are back.', true);
}finally{ keeping.delete(url); syncKeepButtons(); }
}
/// Everything kept for offline, gone: at sign-out, and when another account signs in here.
async function clearOffline(){
try{ for(const k of await caches.keys()) if(k.startsWith('ipx-')) await caches.delete(k); }catch{}
kept.clear();
}
function offlineOwner(name: string){
try{