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:
@@ -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()');
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
7
tests/ui/fixtures/offline-show.xml
Normal file
7
tests/ui/fixtures/offline-show.xml
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user