From 2c4d03668fb453561510f00ed9a55d25af4a27f6 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 7 Nov 2026 15:31:04 +0000 Subject: [PATCH] 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: ) 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 Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA --- CLAUDE.md | 4 +- crates/ipx/CHANGELOG.md | 1 + crates/ipx/src/web.rs | 59 +++++++++++++++++++++++++ tests/page-unit.ts | 16 +++++++ tests/ui/app.spec.ts | 8 +++- tests/ui/fixtures/offline-show.xml | 7 +++ tests/ui/fixtures/serve.ts | 7 +++ tests/ui/offline.spec.ts | 71 ++++++++++++++++++++++++++++++ web/CHANGELOG.md | 1 + web/app.css | 3 ++ web/src/items.ts | 2 + web/src/offline.ts | 59 +++++++++++++++++++++++++ web/src/player.ts | 9 +++- web/src/util.ts | 2 + web/sw/sw.ts | 42 +++++++++++++++++- 15 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 tests/ui/fixtures/offline-show.xml diff --git a/CLAUDE.md b/CLAUDE.md index cd0dc25..f0c325a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,9 @@ own globals: the page (`tsconfig.json`, which also writes the page's declaration The service worker that keeps iPX readable offline (#389) is TypeScript too, `web/sw/sw.ts`, a script of its own built to `/sw.js`, and checked by `tsconfig.sw.json` against a worker's globals, which cannot share a program with the page's. It goes to the network first for -everything, so online nothing is an old copy. The browser tests block it +everything, so online nothing is an old copy, but for an episode someone kept on the device +(#412): the page asks for it as `/media/...?offline`, and the worker answers from its +`ipx-episodes` cache, byte ranges and all. The browser tests block it (`serviceWorkers: 'block'`), since it answers before `page.route` can; the offline tests allow it. The first type-checks `web/src` in TypeScript's `strict` mode: every parameter and variable has a diff --git a/crates/ipx/CHANGELOG.md b/crates/ipx/CHANGELOG.md index c3541da..65aed14 100644 --- a/crates/ipx/CHANGELOG.md +++ b/crates/ipx/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A place in an episode saved from DeadAir's shortened version names its file (`deadair` in `POST .../position`), so it is read in that version's clock even when ipx never handed out its playlist, as for a copy kept on the device or after a restart (#412). - `/api/art` also serves the pictures in the text of items iPX has sent, through its artwork cache, so the page can keep them to read offline (#389). - The log has a line for each swipe in the page's reader, saying what came of it and where the reader stood, and a warning when a drag would not scroll the reader, with what lay over it (#384). - `/metrics` says how long statements take and, for hand-written SQL, how long they waited for a connection (#367); counts feed failures by kind, new items, downloads saved and failed, and the silence DeadAir found (#368); and holds the page's own timings, a click to its list, play to sound, a stall (#369). diff --git a/crates/ipx/src/web.rs b/crates/ipx/src/web.rs index a331592..85c4e28 100644 --- a/crates/ipx/src/web.rs +++ b/crates/ipx/src/web.rs @@ -1500,6 +1500,50 @@ mod tests { assert_eq!(ask(&app, "POST", "/api/login", "10.0.0.9", &[], login("correct horse")).await.0, 429); } + #[tokio::test] + async fn a_place_saved_in_deadairs_clock_is_read_in_it_without_the_playlist() { + let (app, ctx) = app(crate::config::Config::default()).await; + let db = &ctx.db; + let u = db.create_user("dee", None, false).await.unwrap(); + db.subscribe(u, "f").await.unwrap(); + for sql in [ + "INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'g', 1, 600)", + "INSERT INTO entries (feed_id, guid, first_seen, duration) VALUES ('f', 'h', 1, 600)", + "INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at) + VALUES (7, 'f', 'g', 'http://x/a.mp3', '/x/a.mp3', 'done', 100)", + "INSERT INTO enclosures (id, feed_id, guid, url, path, state, downloaded_at) + VALUES (8, 'f', 'h', 'http://x/b.mp3', '/x/b.mp3', 'done', 100)", + ] { + db.sql(sql).await.unwrap(); + } + // Two minutes cut, both before 3:20 of the shortened version. + let plans = r#"{"300": {"cuts": [[60000,120000],[200000,260000]], "pieces": []}}"#; + db.store_plans(7, plans).await.unwrap(); + db.store_plans(8, plans).await.unwrap(); + db.set_deadair(u, "f", Some(true)).await.unwrap(); + db.create_session(u, "s1").await.unwrap(); + let ctx = &ctx; + let place = |guid: &'static str| async move { + let order = crate::db::order_sql("published", "desc"); + let rows = ctx.db.entries_in(u, Some("f"), crate::db::Filter::All, None, 0, 10, &order).await.unwrap(); + rows.into_iter().find(|r| r.guid == guid).unwrap().position + }; + let save = |guid: &str, body: serde_json::Value| { + let uri = format!("/api/entries/f/{guid}/position"); + let app = app.clone(); + async move { ask(&app, "POST", &uri, "10.0.0.5", &[("cookie", "ipx_session=s1")], Some(body)).await.0 } + }; + // Naming another item's file is taken as no file: the place stays as sent. + assert_eq!(save("g", serde_json::json!({ "secs": 200, "deadair": 8 })).await, 204); + assert_eq!(place("g").await, 200); + // From a copy kept on the device, no playlist asked for: 3:20 of it is 5:20 of the original. + assert_eq!(save("g", serde_json::json!({ "secs": 200, "deadair": 7 })).await, 204); + assert_eq!(place("g").await, 320); + // The original's own clock, from a player that says nothing of DeadAir, is left as it is. + assert_eq!(save("h", serde_json::json!({ "secs": 200 })).await, 204); + assert_eq!(place("h").await, 200); + } + #[test] fn a_window_of_wrong_passwords_ends_and_is_forgotten() { let mut wrong = HashMap::new(); @@ -2785,6 +2829,10 @@ struct Position { secs: i64, /// The length the player measured, for an episode whose feed gives none. duration: Option, + /// The file, when the player was playing it without its dead air: its places are in that + /// clock. The playlist tells the server so too, but a copy kept on the device (#412) plays + /// without one, and the server forgets what it was told when it restarts. + deadair: Option, } async fn set_position( @@ -2793,6 +2841,17 @@ async fn set_position( user: crate::db::User, Json(body): Json, ) -> Result { + let key = (user.id, feed_id.clone(), guid.clone()); + if let Some(id) = body.deadair + && !state.playing.lock().expect("a panic elsewhere poisoned this lock").contains_key(&key) + && let Some(enc) = state.ctx.db.enclosure(id).await? + // Only this item's file: another's plan would put the place somewhere else. + && enc.feed_id == feed_id + && enc.guid == guid + && let Some(plan) = crate::deadair::plan_for(&state.ctx, user.id, &feed_id, id).await? + { + state.playing.lock().expect("a panic elsewhere poisoned this lock").insert(key, Arc::new(plan)); + } let (secs, duration) = crate::deadair::on_save(&state.ctx, &state.playing, user.id, &feed_id, &guid, body.secs, body.duration).await?; state.ctx.db.set_position(user.id, &feed_id, &guid, secs, duration).await?; diff --git a/tests/page-unit.ts b/tests/page-unit.ts index 25706db..89f9982 100644 --- a/tests/page-unit.ts +++ b/tests/page-unit.ts @@ -301,3 +301,19 @@ test('the pictures saved for offline are those in unread items\' text, as the br ])`); assert.deepEqual([...pics], ['https://a.example/1.jpg?w=1&h=2']); }); + +test('an episode kept on the device is the copy it plays, and plays from there (#412)', () => { + // DeadAir's copy for someone it cuts something for, the file itself otherwise: in the clock + // their places are saved in. + assert.equal(run('keepUrl({id: 3, deadair: 40})'), '/media/3/deadair'); + assert.equal(run('keepUrl({id: 3, deadair: 0})'), '/media/3'); + assert.equal(run('keepUrl({id: 3})'), '/media/3'); + assert.equal(run('mediaSrc({id: 3, deadair: 40})'), '/media/3/deadair.m3u8'); + run(`kept.add('/media/3/deadair')`); + assert.equal(run('mediaSrc({id: 3, deadair: 40})'), '/media/3/deadair?offline'); + // Kept as the file, now cut by DeadAir: that copy's clock is not theirs any more. + run(`kept.clear(); kept.add('/media/3')`); + assert.equal(run('mediaSrc({id: 3, deadair: 40})'), '/media/3/deadair.m3u8'); + assert.equal(run('mediaSrc({id: 3, deadair: null})'), '/media/3?offline'); + run('kept.clear()'); +}); diff --git a/tests/ui/app.spec.ts b/tests/ui/app.spec.ts index e7ab95d..537ba76 100644 --- a/tests/ui/app.spec.ts +++ b/tests/ui/app.spec.ts @@ -2494,9 +2494,13 @@ test('a downloaded file\'s buttons sit on one row in the files pane', async ({ p await expect(page.locator('.ep').first()).toBeVisible(); await page.evaluate(() => showDetail(S.entries.find(e => e.enclosures.some(x => x.path)) ?? null)); const box = page.locator('#files .encbox').first(); - await expect(box.locator('.btn')).toHaveCount(4); + await expect(box.locator('.btn')).toHaveCount(5); const tops = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top))); - expect(new Set(tops).size, 'play, save, share and delete wrapped onto two rows').toBe(1); + expect(new Set(tops).size, 'play, keep, save, share and delete wrapped onto two rows').toBe(1); + // And on a phone, where the pane is the whole width (#412 added keep). + await page.setViewportSize({ width: 375, height: 800 }); + const phone = await box.locator('.btn').evaluateAll(bs => bs.map(b => Math.round(b.getBoundingClientRect().top))); + expect(new Set(phone).size, 'on a phone').toBe(1); }); test('a list seen before shows at once, and one not yet seen shows a spinner while it loads', async ({ page }) => { diff --git a/tests/ui/fixtures/offline-show.xml b/tests/ui/fixtures/offline-show.xml new file mode 100644 index 0000000..c6a5723 --- /dev/null +++ b/tests/ui/fixtures/offline-show.xml @@ -0,0 +1,7 @@ + + +Offline Showhttp://127.0.0.1:8792/An episode to keep on the device and play offline (#412). +Listen on a Planeoffline-ep-1 + Mon, 01 Sep 2026 10:00:00 +0000 + + diff --git a/tests/ui/fixtures/serve.ts b/tests/ui/fixtures/serve.ts index 559472b..c14f074 100644 --- a/tests/ui/fixtures/serve.ts +++ b/tests/ui/fixtures/serve.ts @@ -60,6 +60,13 @@ http.createServer((req, res) => { Latest Show${[1, 2, 3, 4].map(item).join('')}`); return; } + // Kept on the device and played offline (#412): real audio, which ep1.mp3 is not, at a URL of + // its own, as enclosure URLs must be. + if (req.url === '/offline-ep.mp3') { + const body = fs.readFileSync(path.join(dir, 'quiet.mp3')); + res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': body.length }).end(body); + return; + } if (/^\/latest-\d\.mp3$/.test(req.url ?? '')) { const body = fs.readFileSync(path.join(dir, 'ep1.mp3')); res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': body.length }).end(body); diff --git a/tests/ui/offline.spec.ts b/tests/ui/offline.spec.ts index 5109016..554ac1c 100644 --- a/tests/ui/offline.spec.ts +++ b/tests/ui/offline.spec.ts @@ -126,6 +126,76 @@ test('kept pictures past the budget go oldest first', async ({ page, context }) expect(kept).toEqual([pic(2), pic(3)]); }); +test('an episode kept on the device plays from it offline, and its place waits to be saved', async ({ page, context }) => { + type Enc = { id: number, path: string | null, state: string }; + const feeds: { id: string, url: string }[] = await call(page, '/api/feeds'); + const id = feeds.find((f) => f.url.endsWith('/offline-show.xml'))?.id + ?? (await call(page, '/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/offline-show.xml' }) }))?.id ?? 'offline-show'; + const enc = async (): Promise => + (await call(page, `/api/feeds/${encodeURIComponent(id)}/entries?offset=0&limit=5&filter=all`))?.entries?.[0]?.enclosures?.[0]; + await expect.poll(async () => (await enc())?.id ?? 0, { timeout: 20_000 }).toBeGreaterThan(0); + const first = (await enc())!; + if (!first.path) await call(page, `/api/enclosures/${first.id}/download`, { method: 'POST' }); + await expect.poll(async () => !!(await enc())?.path, { timeout: 20_000 }).toBe(true); + await call(page, `/api/entries/${encodeURIComponent(id)}/offline-ep-1/position`, { method: 'POST', body: JSON.stringify({ secs: 0 }) }); + await controlled(page); + + const open = async () => { + await page.locator('#feedlist .feed', { hasText: 'Offline Show' }).click({ timeout: 20_000 }); + await page.locator('#content .tabs button[data-f="all"]').click(); + await page.locator('.ep', { hasText: 'Listen on a Plane' }).click(); + }; + await open(); + const keep = page.locator('#files [data-a="offline"]'); + await expect(keep).toHaveAttribute('aria-pressed', 'false'); + await keep.click(); + await expect(keep).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 }); + expect(await cacheSize(page, 'ipx-episodes')).toBe(1); + + await context.setOffline(true); + await page.reload(); + await open(); + // Still kept, as the page reads it from the cache at start. + await expect(keep).toHaveAttribute('aria-pressed', 'true'); + await page.locator('#files [data-a="play"]').click(); + await expect.poll(() => page.evaluate(() => audio.currentTime), { timeout: 15_000 }).toBeGreaterThan(0.3); + expect(await page.evaluate(() => audio.currentSrc)).toContain('?offline'); + // The player saves its place as it pauses: kept for the connection, as a read is. + await page.evaluate(() => audio.pause()); + // (Opening the item read it, which waits beside it.) + await expect.poll(() => page.evaluate(async () => { + const c = await caches.open('ipx-outbox'); + const saved = await Promise.all((await c.keys()).map(async (k) => (await (await c.match(k))!.json()) as { url: string, body: string })); + return saved.filter((w) => w.url.includes('/offline-ep-1/position')).map((w) => JSON.parse(w.body).secs as number); + })).toEqual([expect.any(Number)]); + + await context.setOffline(false); + await page.evaluate(() => window.dispatchEvent(new Event('online'))); + await expect.poll(() => outbox(page), { timeout: 15_000 }).toBe(0); + expect((await call(page, `/api/entries/${encodeURIComponent(id)}/offline-ep-1`)).position).toBeGreaterThanOrEqual(0); + + // Taken off the device again. + await keep.click(); + await expect(keep).toHaveAttribute('aria-pressed', 'false'); + expect(await cacheSize(page, 'ipx-episodes')).toBe(0); +}); + +test('a kept episode is answered in the byte ranges a player asks for', async ({ page, context }) => { + await controlled(page); + const sw = await worker(context); + const got = await sw.evaluate(async () => { + const blob = new Blob(['0123456789']); + const one = async (range: string | null) => { + const r = ranged(blob, range, 'audio/mpeg'); + return [r.status, r.headers.get('Content-Range'), r.status === 416 ? null : await r.text()].filter((x) => x !== null).join(' '); + }; + return [await one(null), await one('bytes=2-4'), await one('bytes=7-'), await one('bytes=-3'), await one('bytes=8-99'), + await one('bytes=10-'), await one('pages=1-2')]; + }); + expect(got).toEqual(['200 0123456789', '206 bytes 2-4/10 234', '206 bytes 7-9/10 789', '206 bytes 7-9/10 789', + '206 bytes 8-9/10 89', '416 bytes */10', '200 0123456789']); +}); + test('another account signing in on the device clears what the last one kept', async ({ page }) => { await controlled(page); // What an account before this one left, and the page last opened as that account. @@ -152,3 +222,4 @@ test('signing out leaves nothing kept for offline on the device', async ({ page // The worker's own functions, called in it by the test above; declared for the type check. declare function keepPictures(srcs: string[], budget?: number): Promise; +declare function ranged(blob: Blob, range: string | null, type: string): Response; diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index b86f5eb..fb9a38e 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -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). diff --git a/web/app.css b/web/app.css index 0fb5255..639f464 100644 --- a/web/app.css +++ b/web/app.css @@ -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} diff --git a/web/src/items.ts b/web/src/items.ts index 4f0207d..4f39f78 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -496,6 +496,7 @@ function encBox(x: Enclosure){ ${kindIcon(x)} ${size} + ${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(); } diff --git a/web/src/offline.ts b/web/src/offline.ts index 1cefb42..f18403b 100644 --- a/web/src/offline.ts +++ b/web/src/offline.ts @@ -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(); +/// 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(); +})(); +function syncKeepButtons(){ + document.querySelectorAll('[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 ``; +} +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{ diff --git a/web/src/player.ts b/web/src/player.ts index bbeebbc..28adc5e 100644 --- a/web/src/player.ts +++ b/web/src/player.ts @@ -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();$('#pplay').innerHTML=ICON.play}); diff --git a/web/src/util.ts b/web/src/util.ts index 22fbf59..8ddbd42 100644 --- a/web/src/util.ts +++ b/web/src/util.ts @@ -41,6 +41,8 @@ const ICON: Record={ pinOn:fa('-18 -18 420 548',''), // solid/thumbtack scan:fa('0 0 512 512',''), // solid/arrows-rotate download:fa('0 0 448 512',''), // solid/download + keep:fa('0 0 512 512',''), // regular/circle-down + kept:fa('0 0 512 512',''), // solid/circle-down save:fa('0 0 448 512',''), // solid/floppy-disk trash:fa('0 0 448 512',''), // solid/trash-can open:fa('0 0 512 512',''), // solid/arrow-up-right-from-square diff --git a/web/sw/sw.ts b/web/sw/sw.ts index 3013d4b..e82142c 100644 --- a/web/sw/sw.ts +++ b/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 { 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 { + 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)}`;