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
152 lines
7.3 KiB
TypeScript
152 lines
7.3 KiB
TypeScript
/* ---------------- offline (#389) ---------------- */
|
||
// The service worker (web/sw.js) keeps the page, what it read from the API and the pictures in
|
||
// unread items' text, and holds reads, flags and places made offline until the connection is
|
||
// back. Only in a secure context: over plain HTTP on a LAN address there is none, and iPX is as
|
||
// it was.
|
||
const worker=('serviceWorker' in navigator) ? navigator.serviceWorker : null;
|
||
worker?.register('/sw.js').catch(()=>{});
|
||
const toWorker=(msg: object)=>worker?.controller?.postMessage(msg);
|
||
/// What waited for the connection goes now: on its return, and when the live stream comes back.
|
||
function flushOutbox(){ toWorker({flush:true}); }
|
||
window.addEventListener('online',flushOutbox);
|
||
window.addEventListener('offline',()=>toast('You are offline. What iPX has saved still opens, and what you read is sent when you are back.'));
|
||
|
||
/// The pictures in the text of the unread items among `entries`, as their HTML names them.
|
||
function textPictures(entries: Entry[]): string[]{
|
||
const out=new Set<string>();
|
||
for(const e of entries){
|
||
if(e.read) continue;
|
||
for(const html of [e.description,e.markdown_html]){
|
||
for(const m of (html||'').matchAll(/<img\b[^>]*?\ssrc="([^"]+)"/g)){
|
||
const src=m[1]!.replace(/"/g,'"').replace(/ /g,' ').replace(/&/g,'&');
|
||
if(/^https?:\/\//.test(src)) out.add(src);
|
||
}
|
||
}
|
||
}
|
||
return [...out];
|
||
}
|
||
/// Asks the worker to keep, through ipx, the pictures of a list's unread items for offline.
|
||
function savePictures(entries: Entry[]){
|
||
const p=textPictures(entries);
|
||
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();
|
||
})();
|
||
/// 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(){
|
||
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);
|
||
}
|
||
/// 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);
|
||
if(keeping.has(url)) return;
|
||
// In the iOS app the phone keeps the file and its player plays it (#425): a WebView runs no
|
||
// service worker, and the app's player could not read the page's cache if it did. The app
|
||
// answers with what it holds, hostKept below.
|
||
if(ipxHost){
|
||
if(kept.has(url)){ ipxHost({t:'unkeep',url}); toast('Removed from this device: '+name); return; }
|
||
keeping.add(url); syncKeepButtons();
|
||
toast('Keeping on this device: '+name);
|
||
ipxHost({t:'keep',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(); }
|
||
}
|
||
|
||
/// What the app holds and is fetching (#425), told when the page starts and after each change,
|
||
/// or that one could not be kept. Only a file asked for here says it was kept: the list at start
|
||
/// is no news.
|
||
function hostKept(m: {t: string, urls?: string[], keeping?: string[], url?: string, message?: string}){
|
||
if(m.t==='keepFailed'){
|
||
keeping.delete(m.url||'');
|
||
toast(m.message||'Unable to keep it on this device. Try again.',true);
|
||
syncKeepButtons();
|
||
return;
|
||
}
|
||
const asked=new Set(keeping);
|
||
kept.clear(); for(const u of m.urls||[]) kept.add(u);
|
||
keeping.clear(); for(const u of m.keeping||[]) keeping.add(u);
|
||
for(const u of asked) if(kept.has(u)) toast('Kept on this device, to play offline');
|
||
syncKeepButtons();
|
||
}
|
||
|
||
/// Everything kept for offline, gone: at sign-out, and when another account signs in here.
|
||
async function clearOffline(){
|
||
ipxHost?.({t:'clearKept'});
|
||
try{ for(const k of await caches.keys()) if(k.startsWith('ipx-')) await caches.delete(k); }catch{}
|
||
kept.clear();
|
||
}
|
||
function offlineOwner(name: string){
|
||
try{
|
||
const was=localStorage.getItem('ipx.owner');
|
||
if(was!==null&&was!==name) clearOffline();
|
||
localStorage.setItem('ipx.owner',name);
|
||
}catch{}
|
||
}
|