A kept episode's row says On this device (#413)

Only the keep button in the files pane said an episode was kept, so finding
what was on the device meant opening items one by one. The row's line says
it, and keeping or removing one redraws the rows whose state changed.

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 17:36:12 +00:00
parent b209f55d2c
commit 6d64cba374
5 changed files with 14 additions and 1 deletions

View File

@@ -147,16 +147,21 @@ test('an episode kept on the device plays from it offline, and its place waits t
}; };
await open(); await open();
const keep = page.locator('#files [data-a="offline"]'); const keep = page.locator('#files [data-a="offline"]');
const row = page.locator('.ep', { hasText: 'Listen on a Plane' });
await expect(keep).toHaveAttribute('aria-pressed', 'false'); await expect(keep).toHaveAttribute('aria-pressed', 'false');
await expect(row.locator('.kept')).toHaveCount(0);
await keep.click(); await keep.click();
await expect(keep).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 }); await expect(keep).toHaveAttribute('aria-pressed', 'true', { timeout: 15_000 });
expect(await cacheSize(page, 'ipx-episodes')).toBe(1); expect(await cacheSize(page, 'ipx-episodes')).toBe(1);
// Its row in the list says so too (#413).
await expect(row.locator('.kept')).toHaveText('On this device');
await context.setOffline(true); await context.setOffline(true);
await page.reload(); await page.reload();
await open(); await open();
// Still kept, as the page reads it from the cache at start. // Still kept, as the page reads it from the cache at start.
await expect(keep).toHaveAttribute('aria-pressed', 'true'); await expect(keep).toHaveAttribute('aria-pressed', 'true');
await expect(row.locator('.kept')).toBeVisible();
await page.locator('#files [data-a="play"]').click(); await page.locator('#files [data-a="play"]').click();
await expect.poll(() => page.evaluate(() => audio.currentTime), { timeout: 15_000 }).toBeGreaterThan(0.3); await expect.poll(() => page.evaluate(() => audio.currentTime), { timeout: 15_000 }).toBeGreaterThan(0.3);
expect(await page.evaluate(() => audio.currentSrc)).toContain('?offline'); expect(await page.evaluate(() => audio.currentSrc)).toContain('?offline');
@@ -178,6 +183,7 @@ test('an episode kept on the device plays from it offline, and its place waits t
await keep.click(); await keep.click();
await expect(keep).toHaveAttribute('aria-pressed', 'false'); await expect(keep).toHaveAttribute('aria-pressed', 'false');
expect(await cacheSize(page, 'ipx-episodes')).toBe(0); expect(await cacheSize(page, 'ipx-episodes')).toBe(0);
await expect(row.locator('.kept')).toHaveCount(0);
}); });
test('a kept episode is answered in the byte ranges a player asks for', async ({ page, context }) => { test('a kept episode is answered in the byte ranges a player asks for', async ({ page, context }) => {

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- An episode kept on this device says so in its row of the list, On this device, not only in its files (#413).
- 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). - 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). - 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). - 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

@@ -1072,6 +1072,8 @@ body.playing .eq i:nth-child(3){animation-delay:-.6s}
.ep.read .t{color:var(--dim);font-weight:500} .ep.read .t{color:var(--dim);font-weight:500}
.ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px} .ep .line{display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--faint);font-size:11.5px}
.ep .line:empty{display:none} .ep .line:empty{display:none}
.ep .kept{display:inline-flex;align-items:center;gap:4px;color:var(--dim)}
.ep .kept svg{width:1em;height:1em}
.ep .fd,.ep .size,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .ep .fd,.ep .size,.ep .date{color:var(--dim);font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ep .file{display:flex;gap:6px;align-items:center;position:relative;color:var(--faint);font-size:11.5px} .ep .file{display:flex;gap:6px;align-items:center;position:relative;color:var(--faint);font-size:11.5px}
/* Under the icon, not beside it or on a line of its own: taking a line, it lifted the icon of /* Under the icon, not beside it or on a line of its own: taking a line, it lifted the icon of

View File

@@ -193,6 +193,8 @@ function epEl(e: Entry){
others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`, others>0&&`<span>+${others} more file${others===1?'':'s'}</span>`,
enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`, enc&&enc.deadair&&enc.deadair>0&&`<span title="DeadAir cut its dead air">${shorter(enc.deadair)}</span>`,
enc&&enc.deadair_pending&&DEAD_PENDING, enc&&enc.deadair_pending&&DEAD_PENDING,
// Kept on this device to play offline (#413), which only the files pane said.
e.enclosures.some(isKept)&&`<span class="kept">${ICON.kept}On this device</span>`,
enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`, enc&&enc.last_error&&enc.state==='error'&&`<span style="color:var(--bad)">${esc(enc.last_error)}</span>`,
].filter(Boolean).join('<span class="dot"></span>')}</div> ].filter(Boolean).join('<span class="dot"></span>')}</div>
</div> </div>

View File

@@ -54,7 +54,9 @@ function syncKeepButtons(){
b.innerHTML=k?ICON.kept:ICON.keep; 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'; 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); b.title=label; b.setAttribute('aria-label',label);
}); }); // 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);
} }
/// The button for a file on the server that plays; none without a worker to play it offline. /// The button for a file on the server that plays; none without a worker to play it offline.
function keepBtn(x: Enclosure){ function keepBtn(x: Enclosure){