/* ---------------- 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(); for(const e of entries){ if(e.read) continue; for(const html of [e.description,e.markdown_html]){ for(const m of (html||'').matchAll(/]*?\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(); /// 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(); 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(); 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{} }