From a30edff24808a19d126c2ed940c994f91738f93b Mon Sep 17 00:00:00 2001 From: rays Date: Mon, 14 Sep 2026 15:38:03 +0000 Subject: [PATCH] Release 0.5.4: remembered view, Auto theme, Currently Listening - Remember the feed/place and tab across a reload or new visit; an unknown or unsubscribed one lands on All Subscriptions instead of the first feed alphabetically. - Add an Auto theme that follows the system's light/dark setting, and move Dark/Light/Classic/Auto into Settings as a dropdown alongside the header button's toggle. - Add Currently Listening below Popular: episodes started and not finished, across every subscribed feed, one tap to resume. Reuses the existing entries/filter machinery (Filter::InProgress) rather than a new endpoint. - Likely fix for the iOS bug where the topbar stopped responding to taps until a hard refresh (100vh -> 100dvh); unverified on a real device. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA --- CHANGELOG.md | 26 ++++++++++- Cargo.lock | 2 +- Cargo.toml | 2 +- TODO.md | 8 ++-- src/db.rs | 33 +++++++++++--- tests/ui/app.spec.js | 84 +++++++++++++++++++++++++++++++----- web/index.html | 100 +++++++++++++++++++++++++++++++++++++++---- 7 files changed, 222 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac8e4b..2fb79c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,29 @@ The long form, with what was wrong before and how it was found, is in ## [Unreleased] +## [0.5.4] - 2026-09-14 + +### Added + +- Currently Listening, below Popular: episodes you started and have not finished, across every + feed you subscribe to. Tap one to pick up where you left off. +- Theme has an Auto option, alongside Dark, Light and Classic, that follows your system's + light/dark setting. All four are now also in Settings, as a dropdown next to the header + button's one-click-at-a-time toggle -- the same setting either way. + +### Changed + +- The feed (or Directory/Popular/All Subscriptions) and the tab you had open are remembered + across a reload or a new visit. A feed you no longer subscribe to, or a first visit with + nothing remembered yet, lands on All Subscriptions instead of the first feed alphabetically. + +### Fixed + +- On iOS, the topbar (the hamburger menu included) could stop responding to taps until a hard + refresh. The page sized itself with `100vh`, which iOS Safari measures against the address + bar's collapsed state rather than what is actually visible; `100dvh` tracks the real viewport + as the bar shows and hides. + ## [0.5.3] - 2026-09-14 ### Added @@ -332,7 +355,8 @@ The long form, with what was wrong before and how it was found, is in - Torrent enclosures through librqbit, seeding to a ratio or a time, with a stall timeout. - `ipx import` and `ipx export` for OPML, and systemd units in `contrib/`. -[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...main +[unreleased]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.4...main +[0.5.4]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.3...v0.5.4 [0.5.3]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.2...v0.5.3 [0.5.2]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.1...v0.5.2 [0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1 diff --git a/Cargo.lock b/Cargo.lock index cb67755..2e82b41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "ipx" -version = "0.5.3" +version = "0.5.4" dependencies = [ "ammonia", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index eebb6f7..055ef77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ipx" -version = "0.5.3" +version = "0.5.4" edition = "2024" [dependencies] diff --git a/TODO.md b/TODO.md index 9872c44..cbfb4d9 100644 --- a/TODO.md +++ b/TODO.md @@ -43,12 +43,12 @@ User-Agent; a browser gets the same answers. ## Other Fixes and Features -- [ ] Remember which feed is selected and view (all, unread, flagged, etc) user as selected between visits. If unknown default to All Subscriptions +- [x] Remember which feed is selected and view (all, unread, flagged, etc) user as selected between visits. If unknown default to All Subscriptions - [x] When clicking any link it should open in a new tab -- [ ] In mobile (iOS) sometimes the top line items like the hamburger menu are not clickable unless you do a hard refresh +- [x] In mobile (iOS) sometimes the top line items like the hamburger menu are not clickable unless you do a hard refresh — likely fixed (100dvh instead of 100vh), unverified on a real device; reopen if it still happens - [x] video files play as audio files, they should play as video. -- [ ] Move Light/Dark/Classic options to user settings. Include an Auto mode that uses system preferences for light/dark modes -- [ ] Below Popular, have a currently listening section to show what podcasts have been started and not finnished +- [x] Move Light/Dark/Classic options to user settings. Include an Auto mode that uses system preferences for light/dark modes +- [x] Below Popular, have a currently listening section to show what podcasts have been started and not finnished - [x] Update subscribe/unsubscribe icons to be circle-minus (unsubscribe) and circle-check (subscribe) - [x] If I'm on the Unread tab, and I click to read an item the entry in the list will disappear. it should remain until I click to another item. diff --git a/src/db.rs b/src/db.rs index fcc761c..abde372 100644 --- a/src/db.rs +++ b/src/db.rs @@ -685,6 +685,10 @@ pub enum Filter { Unread, Downloaded, Flagged, + /// Started (a saved playback position past the first few seconds) but not finished + /// (`markPlayed` in the UI marks an item read at 90% played, so unread is "not finished" + /// here too). Currently Listening, below Popular, is this filter on every feed at once. + InProgress, } impl Filter { @@ -693,6 +697,7 @@ impl Filter { "unread" => Self::Unread, "downloaded" => Self::Downloaded, "flagged" => Self::Flagged, + "in_progress" => Self::InProgress, _ => Self::All, } } @@ -708,6 +713,7 @@ impl Filter { "EXISTS (SELECT 1 FROM enclosures x WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)" } + Self::InProgress => "coalesce(s.position, 0) > 5 AND coalesce(s.read, 0) = 0", } } } @@ -1656,18 +1662,27 @@ mod tests { "INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES ('f','a','Alpha dive','notes one',100), ('f','b','Beta', 'notes two',200), - ('f','c','Gamma dive','notes three',300); + ('f','c','Gamma dive','notes three',300), + ('f','d','Delta', 'notes four',400), + ('f','e','Epsilon', 'notes five',500), + ('f','g','Gimel', 'notes six',600); INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES (1,'f','b','u1','/tmp/b','done'); -- Read and starred belong to a person now, so say which one. INSERT INTO users (id, name, is_admin) VALUES (7,'reader',1); - INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES - (7,'f','b',1,0), - (7,'f','c',1,1);", + INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES + (7,'f','b',1,0,0), + (7,'f','c',1,1,0), + -- Started and not finished: this is Currently Listening. + (7,'f','d',0,0,42), + -- Already finished: not Currently Listening, however far it got. + (7,'f','e',1,0,42), + -- Barely touched (opened, closed within seconds): not Currently Listening. + (7,'f','g',0,0,3);", ) .unwrap(); - for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged] { + for f in [Filter::All, Filter::Unread, Filter::Downloaded, Filter::Flagged, Filter::InProgress] { // Both paths must run without erroring, and agree with each other. let order = order_sql("published", "desc"); let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).unwrap(); @@ -1679,13 +1694,17 @@ mod tests { assert_eq!(rows.len() as i64, n, "{f:?} with search disagrees"); } - assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 3); - assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 1); + assert_eq!(db.count_in(7, Some("f"), Filter::All, None).unwrap(), 6); + assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 3); assert_eq!(db.count_in(7, Some("f"), Filter::Downloaded, None).unwrap(), 1); assert_eq!(db.count_in(7, Some("f"), Filter::Flagged, None).unwrap(), 1); assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("dive")).unwrap(), 2); assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).unwrap(), 1, "search is case-insensitive and covers the description"); + + // Currently Listening: started, not finished, and not just an accidental tap. + let listening = db.entries_in(7, Some("f"), Filter::InProgress, None, 0, 50, &order_sql("published", "desc")).unwrap(); + assert_eq!(listening.iter().map(|e| e.guid.as_str()).collect::>(), ["d"]); } #[test] diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 2f43d1b..1c32655 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -12,7 +12,7 @@ test('the page loads and lists the configured feeds', async ({ page }) => { // empty, with every handler below the error dead. Server-side checks all passed. // Four top-level feeds in the fixture config; the OPML's children are inside a closed folder. await expect(page.locator('.feed')).toHaveCount(5, { timeout: 15_000 }); - await expect(page.getByText('Test Show')).toBeVisible(); + await expect(page.locator('.feed', { hasText: 'Test Show' })).toBeVisible(); const errors = []; page.on('pageerror', e => errors.push(e.message)); await page.reload(); @@ -33,7 +33,7 @@ test('the theme button steps through dark, light and classic, and remembers', as const theme = () => page.evaluate(() => document.documentElement.dataset.theme); for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click(); expect(await theme()).toBe('classic'); - await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Dark/); + await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Auto/); await page.reload(); await expect.poll(theme).toBe('classic'); @@ -41,6 +41,28 @@ test('the theme button steps through dark, light and classic, and remembers', as expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande'); }); +test('the theme dropdown in Settings jumps straight to a theme, including Auto', async ({ page }) => { + const theme = () => page.evaluate(() => document.documentElement.dataset.theme); + await page.locator('#prefs').click(); + await expect(page.locator('#stheme')).toHaveValue(await theme()); + + await page.locator('#stheme').selectOption('auto'); + await expect.poll(theme).toBe('auto'); + // Auto follows the system; emulating a light system must show the light palette live, + // no reload needed, since it is a media query rather than something JS picks per click. + await page.emulateMedia({ colorScheme: 'light' }); + await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor)) + .toBe('rgb(242, 244, 247)'); // --bg in the light palette + await page.emulateMedia({ colorScheme: 'dark' }); + await expect.poll(() => page.evaluate(() => getComputedStyle(document.body).backgroundColor)) + .toBe('rgb(14, 19, 27)'); // the bare :root is already dark; Auto adds nothing here + + // The header button and the dropdown are the same one setting, not two. + await page.locator('#modalCard .cardacts .btn').first().click(); // Cancel, closing the modal + await page.locator('#theme').click(); + expect(await theme()).toBe('dark'); +}); + test('settings opens and saves the global schedule', async ({ page }) => { await page.locator('#prefs').click(); await expect(page.locator('#modal.on')).toBeVisible(); @@ -57,7 +79,7 @@ test('settings opens and saves the global schedule', async ({ page }) => { }); test('episodes show with their metadata, and the text opens below', async ({ page }) => { - await page.getByText('Test Show').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); await expect(page.getByText('First Episode')).toBeVisible(); // Newest first, so target the episode by name rather than by position. @@ -74,7 +96,7 @@ test('episodes show with their metadata, and the text opens below', async ({ pag }); test('the three panes are there and the item text lands in the bottom one', async ({ page }) => { - await page.getByText('Test Show').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); await expect(page.locator('#list')).toBeVisible(); await expect(page.locator('#grab')).toBeVisible(); // the draggable divider await expect(page.locator('#detail')).toContainText('Pick an item'); @@ -133,8 +155,34 @@ test('an item with several enclosures lists them all', async ({ page }) => { await expect(page.locator('#files .encbox').nth(1).locator('.kind[title^="image"]')).toBeVisible(); }); +test('Currently Listening, below Popular, resumes an episode you started', async ({ page }) => { + // Second Episode (900s) is 42 seconds in and unfinished. Which of Test Show's two episodes + // the daemon auto-downloaded is not fixed (see the three-panes test above), so an earlier + // test may have opened -- and so read -- this one already; reset it before relying on it. + await page.evaluate(() => + api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: false }) })); + await page.evaluate(() => + api('/api/entries/test-show/ui-2/position', { method: 'POST', body: JSON.stringify({ secs: 42 }) })); + + await page.locator('#feedlist .place', { hasText: 'Popular' }).click(); + const row = page.locator('#listening .childrow', { hasText: 'Second Episode' }); + await expect(row).toBeVisible({ timeout: 20_000 }); + await expect(row).toContainText('0:42 of 15:00'); + + await row.click(); + await expect(page.locator('#player')).toBeVisible(); + await expect(page.locator('#ptitle')).toHaveText('Second Episode'); + await page.locator('#pclose').click(); + + // Finished (read) drops it from the list, however far it got. + await page.evaluate(() => + api('/api/entries/test-show/ui-2/flags', { method: 'POST', body: JSON.stringify({ read: true }) })); + await page.locator('#feedlist .place', { hasText: 'Popular' }).click(); + await expect(page.locator('#listening')).not.toContainText('Second Episode', { timeout: 20_000 }); +}); + test('the filter tabs change what is listed', async ({ page }) => { - await page.getByText('Test Show').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); const all = await page.locator('.ep').count(); // All is the default tab await expect(page.locator('#count')).toContainText('item'); @@ -147,7 +195,7 @@ test('the filter tabs change what is listed', async ({ page }) => { }); test('a feed URL is editable and has a copy button', async ({ page }) => { - await page.getByText('Test Show').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('#content .acts [data-a="settings"]').click(); await expect(page.locator('#surl')).toHaveValue(/show\.xml/); await expect(page.locator('#scopy')).toBeVisible(); @@ -288,7 +336,7 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag const errors = []; page.on('pageerror', e => errors.push(e.message)); - await page.getByText('Test Show').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); const row = () => page.locator('.ep', { hasText: 'Second Episode' }); await expect(row()).toBeVisible({ timeout: 20_000 }); @@ -307,7 +355,7 @@ test('opening an item marks it read, and the toggle flips it back', async ({ pag }); test('the toolbar acts on the selected item', async ({ page }) => { - await page.getByText('Test Show').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); const row = () => page.locator('.ep', { hasText: 'Second Episode' }); await expect(row()).toBeVisible({ timeout: 20_000 }); // Nothing selected, nothing to act on. @@ -395,7 +443,7 @@ test('a second person has their own feeds and their own read state', async ({ br test('deleting a shared file warns that it is everyone\'s copy', async ({ page }) => { // Admin and Sam both subscribe to Test Show by now, and the daemon downloaded a file. - await page.getByText('Test Show').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('.tabs button', { hasText: 'Downloaded' }).click(); const row = page.locator('.ep').first(); await expect(row).toBeVisible({ timeout: 20_000 }); @@ -419,7 +467,7 @@ test('deleting a shared file warns that it is everyone\'s copy', async ({ page } expect(seen[1]).toContain('one copy of this file'); await page.reload(); - await page.getByText('Test Show').click(); + await page.locator('.feed', { hasText: 'Test Show' }).click(); await page.locator('.tabs button', { hasText: 'Downloaded' }).click(); await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); }); @@ -791,6 +839,22 @@ test('the item table sorts by any column, both ways, and remembers', async ({ pa await expect(page.locator('#eps .ep .file', { hasText: /\d/ })).toHaveCount(0); }); +test('the selected feed and tab are remembered across a reload', async ({ page }) => { + await page.locator('#feedlist .feed', { hasText: 'Test Show' }).first().click(); + await page.locator('.tabs button', { hasText: 'Unread' }).click(); + await expect(page.locator('.tabs button.on')).toHaveText('Unread'); + + await page.reload(); + await expect(page.locator('#content h2')).toHaveText('Test Show'); + await expect(page.locator('.tabs button.on')).toHaveText('Unread'); + + // A feed that is gone -- unsubscribed, or never visited on this browser -- lands on All + // Subscriptions, not the first feed alphabetically. + await page.evaluate(() => localStorage.setItem('ipx.feed', 'no-such-feed')); + await page.reload(); + await expect(page.locator('#feedlist .place.sel')).toContainText('All Subscriptions'); +}); + test('play in the Files pane plays once, in the player bar', async ({ page }) => { // Regression: the pane had an