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:
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Keep an episode on this device to listen offline: the button beside Play on a downloaded episode keeps the file, without its dead air if DeadAir shortens it for you, and it then plays from the device whether you are online or not, with your place sent once the connection is back. Press it again to take it off; signing out takes everything off (#412).
|
||||
- iPX opens and reads without a connection: the lists it has fetched (All Subscriptions, Unread and the feeds it preloads or you opened) are kept on the device with the pictures in their unread items, up to 200 MB of them, and items read or flagged and places in an episode while offline are sent when the connection is back. Signing out clears it all (#389).
|
||||
- An open page, or the home-screen app, takes up a new version of iPX by itself once it reconnects after a deploy, back on the item you had open; while something is playing, a dialog is open or a search is typed, it offers a reload instead (#388).
|
||||
|
||||
|
||||
@@ -1013,8 +1013,11 @@ body.adminpage #log{max-width:none}
|
||||
/* The reader's and a feed's pin button, and the toolbar's, pinned: filled with the accent, as the
|
||||
disc is. A solid pin on the button's own grey read the same as an outlined one (#148). */
|
||||
.btn.ico[data-a=pin][aria-pressed=true],.btn.ico[data-a=pin][aria-pressed=true]:hover,
|
||||
.btn.ico[data-a=offline][aria-pressed=true],.btn.ico[data-a=offline][aria-pressed=true]:hover,
|
||||
.btn.ico[data-a=flag][aria-pressed=true],.btn.ico[data-a=flag][aria-pressed=true]:hover,
|
||||
#tbFlag[aria-pressed=true],#tbFlag[aria-pressed=true]:hover{background:var(--accent);border-color:var(--accent);color:var(--ink)}
|
||||
/* An episode on its way to the device (#412). */
|
||||
.btn.ico[aria-busy=true]{opacity:.5;cursor:progress}
|
||||
/* The icon's EQ bars mark what is playing: standing still, and moving only while it plays. A
|
||||
paused animation was tried first; the frames it held were a pixel apart and read as dots. */
|
||||
.eq{display:inline-flex;align-items:flex-end;gap:2px;width:13px;height:12px;flex:none}
|
||||
|
||||
@@ -496,6 +496,7 @@ function encBox(x: Enclosure){
|
||||
${kindIcon(x)}
|
||||
<span class="meta" style="flex:1">${size}</span>
|
||||
<button class="btn ico" data-a="play" data-enc="${x.id}" title="Play" aria-label="Play">${ICON.play}</button>
|
||||
${keepBtn(x)}
|
||||
${saveBtn}
|
||||
${shareBtn}
|
||||
${delBtn}
|
||||
@@ -522,6 +523,7 @@ async function epAction(a: string, e: Entry, el: HTMLElement|null, encId?: numbe
|
||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||
try{
|
||||
if(a==='play') play(e, encId!=null ? enc : undefined);
|
||||
if(a==='offline' && enc) await toggleKept(enc, entryName(e).text);
|
||||
if(a==='share') await share(entryName(e).text, encId!=null ? enc.url : e.link!, el??undefined);
|
||||
if(a==='flag'){ e.flagged=!e.flagged; await api(path+'/flags',{method:'POST',body:JSON.stringify({flagged:e.flagged})}); redraw(); }
|
||||
if(a==='read'){ await setRead(e,!e.read); redraw(); }
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -5,7 +5,9 @@ const player: {guid: string|null, feed: string|null, entry: Entry|null, enc?: nu
|
||||
|
||||
/// Where a file plays from: DeadAir's playlist when the server says this person plays it
|
||||
/// shortened (#171, #181), whose times the entry is already given in, else the file itself.
|
||||
const mediaSrc=(enc: {id: number, deadair?: number|null})=>enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
|
||||
/// One kept on this device (#412) plays from there, online or not: the worker answers `?offline`.
|
||||
const mediaSrc=(enc: {id: number, deadair?: number|null})=>isKept(enc)?`${keepUrl(enc)}?offline`
|
||||
:enc.deadair!=null?`/media/${enc.id}/deadair.m3u8`:`/media/${enc.id}`;
|
||||
|
||||
/// hls.js, playing DeadAir's playlist where the browser cannot itself. Fetched the first time it
|
||||
/// is needed. Only Apple's browsers play it themselves: Chromium says it can ("maybe"), then fails
|
||||
@@ -128,7 +130,10 @@ function savePos(){
|
||||
navigator.sendBeacon?.(
|
||||
// A beacon sends no headers, so the trace goes in the address (#192).
|
||||
`/api/entries/${encodeURIComponent(player.feed!)}/${encodeURIComponent(player.guid!)}/position?traceparent=${traceparent()}`,
|
||||
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
||||
// DeadAir's clock says which file it is, for the server to read the place in it without having
|
||||
// seen its playlist: a kept copy plays offline, and the server may have restarted since.
|
||||
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration,
|
||||
deadair:player.src?.includes('/deadair')?player.enc:undefined})],{type:'application/json'}));
|
||||
}
|
||||
audio.addEventListener('pause',savePos);
|
||||
audio.addEventListener('ended',()=>{savePos();markPlayed();$<HTMLButtonElement>('#pplay').innerHTML=ICON.play});
|
||||
|
||||
@@ -41,6 +41,8 @@ const ICON: Record<string,string>={
|
||||
pinOn:fa('-18 -18 420 548','<path fill="currentColor" d="M32 32C32 14.3 46.3 0 64 0L320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-29.5 0 11.4 148.2c36.7 19.9 65.7 53.2 79.5 94.7l1 3c3.3 9.8 1.6 20.5-4.4 28.8s-15.7 13.3-26 13.3L32 352c-10.3 0-19.9-4.9-26-13.3s-7.7-19.1-4.4-28.8l1-3c13.8-41.5 42.8-74.8 79.5-94.7L93.5 64 64 64C46.3 64 32 49.7 32 32zM160 384l64 0 0 96c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-96z"/>'), // solid/thumbtack
|
||||
scan:fa('0 0 512 512','<path fill="currentColor" d="M65.9 228.5c13.3-93 93.4-164.5 190.1-164.5 53 0 101 21.5 135.8 56.2 .2 .2 .4 .4 .6 .6l7.6 7.2-47.9 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 53.4-11.3-10.7C390.5 28.6 326.5 0 256 0 127 0 20.3 95.4 2.6 219.5 .1 237 12.2 253.2 29.7 255.7s33.7-9.7 36.2-27.1zm443.5 64c2.5-17.5-9.7-33.7-27.1-36.2s-33.7 9.7-36.2 27.1c-13.3 93-93.4 164.5-190.1 164.5-53 0-101-21.5-135.8-56.2-.2-.2-.4-.4-.6-.6l-7.6-7.2 47.9 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 320c-8.5 0-16.7 3.4-22.7 9.5S-.1 343.7 0 352.3l1 127c.1 17.7 14.6 31.9 32.3 31.7S65.2 496.4 65 478.7l-.4-51.5 10.7 10.1c46.3 46.1 110.2 74.7 180.7 74.7 129 0 235.7-95.4 253.4-219.5z"/>'), // solid/arrows-rotate
|
||||
download:fa('0 0 448 512','<path fill="currentColor" d="M256 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 210.7-41.4-41.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l96 96c12.5 12.5 32.8 12.5 45.3 0l96-96c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 242.7 256 32zM64 320c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-46.9 0-56.6 56.6c-31.2 31.2-81.9 31.2-113.1 0L110.9 320 64 320zm304 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/>'), // solid/download
|
||||
keep:fa('0 0 512 512','<path fill="currentColor" d="M256 464a208 208 0 1 1 0-416 208 208 0 1 1 0 416zM256 0a256 256 0 1 0 0 512 256 256 0 1 0 0-512zM244.7 387.3c6.2 6.2 16.4 6.2 22.6 0l104-104c4.6-4.6 5.9-11.5 3.5-17.4S366.5 256 360 256l-72 0 0-104c0-13.3-10.7-24-24-24l-16 0c-13.3 0-24 10.7-24 24l0 104-72 0c-6.5 0-12.3 3.9-14.8 9.9s-1.1 12.9 3.5 17.4l104 104z"/>'), // regular/circle-down
|
||||
kept:fa('0 0 512 512','<path fill="currentColor" d="M256 0a256 256 0 1 0 0 512 256 256 0 1 0 0-512zM244.7 387.3l-104-104c-4.6-4.6-5.9-11.5-3.5-17.4s8.3-9.9 14.8-9.9l56 0 0-96c0-17.7 14.3-32 32-32l32 0c17.7 0 32 14.3 32 32l0 96 56 0c6.5 0 12.3 3.9 14.8 9.9s1.1 12.9-3.5 17.4l-104 104c-6.2 6.2-16.4 6.2-22.6 0z"/>'), // solid/circle-down
|
||||
save:fa('0 0 448 512','<path fill="currentColor" d="M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm32 96c0-17.7 14.3-32 32-32l160 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/>'), // solid/floppy-disk
|
||||
trash:fa('0 0 448 512','<path fill="currentColor" d="M136.7 5.9C141.1-7.2 153.3-16 167.1-16l113.9 0c13.8 0 26 8.8 30.4 21.9L320 32 416 32c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 8.7-26.1zM32 144l384 0 0 304c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-304zm88 64c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24zm104 0c-13.3 0-24 10.7-24 24l0 192c0 13.3 10.7 24 24 24s24-10.7 24-24l0-192c0-13.3-10.7-24-24-24z"/>'), // solid/trash-can
|
||||
open:fa('0 0 512 512','<path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/>'), // solid/arrow-up-right-from-square
|
||||
|
||||
42
web/sw/sw.ts
42
web/sw/sw.ts
@@ -15,7 +15,7 @@ const sw = self as unknown as ServiceWorkerGlobalScope;
|
||||
/// A write kept for later, as the outbox holds it.
|
||||
type Saved = {url: string, body: string, type: string};
|
||||
|
||||
const PAGE = 'ipx-page', API = 'ipx-api', PICS = 'ipx-pics', OUTBOX = 'ipx-outbox';
|
||||
const PAGE = 'ipx-page', API = 'ipx-api', PICS = 'ipx-pics', OUTBOX = 'ipx-outbox', KEPT = 'ipx-episodes';
|
||||
// shortcut: a byte budget over pictures counted when kept, oldest out first; nothing tracks
|
||||
// which were looked at since. Raise it, or prune by use, if 200 MB proves too little or too much.
|
||||
const PICS_BUDGET = 200 << 20;
|
||||
@@ -34,7 +34,13 @@ sw.addEventListener('fetch', e => {
|
||||
if (req.method === 'GET' && req.destination === 'image') e.respondWith(picture(req));
|
||||
return;
|
||||
}
|
||||
// The live stream, audio and video pass by: a stream cannot be kept, a file is too big to.
|
||||
// An episode kept on this device (#412), which the page asks for as such.
|
||||
if (req.method === 'GET' && url.pathname.startsWith('/media/') && url.searchParams.has('offline')) {
|
||||
e.respondWith(keptEpisode(req, url.pathname));
|
||||
return;
|
||||
}
|
||||
// The live stream, audio and video pass by: a stream cannot be kept, a file is too big to
|
||||
// unless someone keeps it, above.
|
||||
if (url.pathname === '/api/events' || url.pathname.startsWith('/media/') || url.pathname === '/sw.js') return;
|
||||
if (req.method !== 'GET') {
|
||||
if (req.method === 'POST' && QUEUED.test(url.pathname)) e.respondWith(write(req));
|
||||
@@ -154,6 +160,38 @@ function flush(): Promise<void> {
|
||||
return flushing;
|
||||
}
|
||||
|
||||
// ---- episodes kept on the device (#412) ------------------------------------------------------
|
||||
|
||||
/// The kept copy, or the byte range of it a player asks for: an audio element asks for ranges and
|
||||
/// Safari plays nothing it cannot have them for. Not kept (cleared, or another device's page), the
|
||||
/// server's own, as without `?offline`.
|
||||
async function keptEpisode(req: Request, path: string): Promise<Response> {
|
||||
const kept = await caches.match(path, { cacheName: KEPT });
|
||||
const range = req.headers.get('Range');
|
||||
if (!kept) return fetch(path, { headers: range ? { Range: range } : {}, credentials: 'same-origin' });
|
||||
const blob = await kept.blob();
|
||||
return ranged(blob, range, kept.headers.get('Content-Type') || 'audio/mpeg');
|
||||
}
|
||||
|
||||
/// `blob` answered as a server answers a Range request: 206 with the part asked for, 416 for a
|
||||
/// part past its end, 200 with all of it when no range is asked for or the one asked is not
|
||||
/// understood.
|
||||
function ranged(blob: Blob, range: string | null, type: string): Response {
|
||||
const size = blob.size;
|
||||
const m = range?.match(/^bytes=(\d*)-(\d*)$/);
|
||||
const head = { 'Content-Type': type, 'Accept-Ranges': 'bytes' };
|
||||
if (!m || (m[1] === '' && m[2] === '')) return new Response(blob, { headers: { ...head, 'Content-Length': String(size) } });
|
||||
// `bytes=-N` is the last N bytes.
|
||||
const start = m[1] === '' ? Math.max(0, size - Number(m[2])) : Number(m[1]);
|
||||
const end = m[1] === '' || m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
|
||||
if (start >= size || start > end) {
|
||||
return new Response(null, { status: 416, headers: { ...head, 'Content-Range': `bytes */${size}` } });
|
||||
}
|
||||
return new Response(blob.slice(start, end + 1), { status: 206, headers: {
|
||||
...head, 'Content-Length': String(end - start + 1), 'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||
} });
|
||||
}
|
||||
|
||||
// ---- pictures ----------------------------------------------------------------------------------
|
||||
|
||||
const artFor = (src: string) => `/api/art?u=${encodeURIComponent(src)}`;
|
||||
|
||||
Reference in New Issue
Block a user