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

@@ -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

View File

@@ -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).

View File

@@ -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<i64>,
/// 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<i64>,
}
async fn set_position(
@@ -2793,6 +2841,17 @@ async fn set_position(
user: crate::db::User,
Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> {
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?;

View File

@@ -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()');
});

View File

@@ -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 }) => {

View File

@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<rss version="2.0">
<channel><title>Offline Show</title><link>http://127.0.0.1:8792/</link><description>An episode to keep on the device and play offline (#412).</description>
<item><title>Listen on a Plane</title><guid>offline-ep-1</guid>
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
<enclosure url="http://127.0.0.1:8792/offline-ep.mp3" type="audio/mpeg" length="60518"/></item>
</channel></rss>

View File

@@ -60,6 +60,13 @@ http.createServer((req, res) => {
<title>Latest Show</title>${[1, 2, 3, 4].map(item).join('')}</channel></rss>`);
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);

View File

@@ -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<Enc | undefined> =>
(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<void>;
declare function ranged(blob: Blob, range: string | null, type: string): Response;

View File

@@ -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).

View File

@@ -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}

View File

@@ -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(); }

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{

View File

@@ -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});

View File

@@ -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

View File

@@ -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)}`;