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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DmQfE1eFPApnXWyPHBWqUA
This commit is contained in:
2026-09-14 15:38:03 +00:00
parent be3820bbbd
commit c48baadbd6
7 changed files with 222 additions and 33 deletions

View File

@@ -10,6 +10,29 @@ The long form, with what was wrong before and how it was found, is in
## [Unreleased] ## [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 ## [0.5.3] - 2026-09-14
### Added ### 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. - 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/`. - `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.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.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 [0.5.1]: https://git.sdf1.net/rays/ipodderx-rs/compare/v0.5.0...v0.5.1

2
Cargo.lock generated
View File

@@ -1605,7 +1605,7 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]] [[package]]
name = "ipx" name = "ipx"
version = "0.5.3" version = "0.5.4"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ipx" name = "ipx"
version = "0.5.3" version = "0.5.4"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@@ -43,12 +43,12 @@ User-Agent; a browser gets the same answers.
## Other Fixes and Features ## 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 - [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. - [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 - [x] 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] 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] 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. - [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.

View File

@@ -685,6 +685,10 @@ pub enum Filter {
Unread, Unread,
Downloaded, Downloaded,
Flagged, 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 { impl Filter {
@@ -693,6 +697,7 @@ impl Filter {
"unread" => Self::Unread, "unread" => Self::Unread,
"downloaded" => Self::Downloaded, "downloaded" => Self::Downloaded,
"flagged" => Self::Flagged, "flagged" => Self::Flagged,
"in_progress" => Self::InProgress,
_ => Self::All, _ => Self::All,
} }
} }
@@ -708,6 +713,7 @@ impl Filter {
"EXISTS (SELECT 1 FROM enclosures x "EXISTS (SELECT 1 FROM enclosures x
WHERE x.feed_id = e.feed_id AND x.guid = e.guid AND x.path IS NOT NULL)" 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 "INSERT INTO entries (feed_id, guid, title, description, first_seen) VALUES
('f','a','Alpha dive','notes one',100), ('f','a','Alpha dive','notes one',100),
('f','b','Beta', 'notes two',200), ('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 INSERT INTO enclosures (id, feed_id, guid, url, path, state) VALUES
(1,'f','b','u1','/tmp/b','done'); (1,'f','b','u1','/tmp/b','done');
-- Read and starred belong to a person now, so say which one. -- 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 users (id, name, is_admin) VALUES (7,'reader',1);
INSERT INTO entry_state (user_id, feed_id, guid, read, flagged) VALUES INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES
(7,'f','b',1,0), (7,'f','b',1,0,0),
(7,'f','c',1,1);", (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(); .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. // Both paths must run without erroring, and agree with each other.
let order = order_sql("published", "desc"); let order = order_sql("published", "desc");
let rows = db.entries_in(7, Some("f"), f, None, 0, 50, &order).unwrap(); 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!(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::All, None).unwrap(), 6);
assert_eq!(db.count_in(7, Some("f"), Filter::Unread, None).unwrap(), 1); 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::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::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("dive")).unwrap(), 2);
assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).unwrap(), 1, assert_eq!(db.count_in(7, Some("f"), Filter::All, Some("NOTES two")).unwrap(), 1,
"search is case-insensitive and covers the description"); "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::<Vec<_>>(), ["d"]);
} }
#[test] #[test]

View File

@@ -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. // 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. // 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.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 = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); page.on('pageerror', e => errors.push(e.message));
await page.reload(); 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); const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click(); for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
expect(await theme()).toBe('classic'); 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 page.reload();
await expect.poll(theme).toBe('classic'); 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'); 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 }) => { test('settings opens and saves the global schedule', async ({ page }) => {
await page.locator('#prefs').click(); await page.locator('#prefs').click();
await expect(page.locator('#modal.on')).toBeVisible(); 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 }) => { 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.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
await expect(page.getByText('First Episode')).toBeVisible(); await expect(page.getByText('First Episode')).toBeVisible();
// Newest first, so target the episode by name rather than by position. // 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 }) => { 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('#list')).toBeVisible();
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
await expect(page.locator('#detail')).toContainText('Pick an item'); 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(); 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 }) => { 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 }); await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
const all = await page.locator('.ep').count(); // All is the default tab const all = await page.locator('.ep').count(); // All is the default tab
await expect(page.locator('#count')).toContainText('item'); 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 }) => { 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 page.locator('#content .acts [data-a="settings"]').click();
await expect(page.locator('#surl')).toHaveValue(/show\.xml/); await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
await expect(page.locator('#scopy')).toBeVisible(); 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 = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); 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' }); const row = () => page.locator('.ep', { hasText: 'Second Episode' });
await expect(row()).toBeVisible({ timeout: 20_000 }); 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 }) => { 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' }); const row = () => page.locator('.ep', { hasText: 'Second Episode' });
await expect(row()).toBeVisible({ timeout: 20_000 }); await expect(row()).toBeVisible({ timeout: 20_000 });
// Nothing selected, nothing to act on. // 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 }) => { 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. // 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(); await page.locator('.tabs button', { hasText: 'Downloaded' }).click();
const row = page.locator('.ep').first(); const row = page.locator('.ep').first();
await expect(row).toBeVisible({ timeout: 20_000 }); 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'); expect(seen[1]).toContain('one copy of this file');
await page.reload(); 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 page.locator('.tabs button', { hasText: 'Downloaded' }).click();
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 }); 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); 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 }) => { test('play in the Files pane plays once, in the player bar', async ({ page }) => {
// Regression: the pane had an <audio> of its own, and playing it started the player bar too, // Regression: the pane had an <audio> of its own, and playing it started the player bar too,
// so the same file played twice at once. // so the same file played twice at once.

View File

@@ -43,6 +43,29 @@
--bad:#b3402f; --bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14); --shadow:0 8px 28px rgba(45,83,145,.14);
} }
/* Auto: the same palette as Light, but only while the system is set to light -- the default
:root above is already dark, so nothing is needed for the dark half of Auto. Duplicated
rather than shared with [data-theme="light"], the same way Classic repeats its own values;
CSS custom properties have no way to say "these vars, but only under this media query". */
@media (prefers-color-scheme: light) {
:root[data-theme="auto"] {
--bg:#f2f4f7;
--panel:#ffffff;
--panel2:#e9edf3;
--raise:#dde3ec;
--line:#d6d6d6;
--fg:#1a1a1a;
--dim:#606060;
--faint:#767676;
--accent:#2d5391;
--accent2:#9a5f0a;
--ink:#ffffff;
--good:#2f7d4f;
--warn:#b06f10;
--bad:#b3402f;
--shadow:0 8px 28px rgba(45,83,145,.14);
}
}
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */ /* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
:root[data-theme="classic"] { :root[data-theme="classic"] {
color-scheme:light; color-scheme:light;
@@ -69,7 +92,14 @@ html,body{height:100%}
body{ body{
margin:0;background:var(--bg);color:var(--fg); margin:0;background:var(--bg);color:var(--fg);
font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; font:14.5px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
display:grid;grid-template-rows:auto 1fr auto auto;height:100vh;overflow:hidden; display:grid;grid-template-rows:auto 1fr auto auto;overflow:hidden;
/* iOS Safari's address bar collapses and expands without firing a resize, so 100vh is
measured against whichever state happened to be current -- sized too tall while the bar
is showing, which puts the topbar (the hamburger included) under Safari's own chrome,
where a tap never reaches the page. Only a hard refresh reset it, forcing 100vh to be
recomputed. 100dvh tracks the real visible viewport as the bar moves; the 100vh above is
the fallback for a browser that does not know dvh. */
height:100vh;height:100dvh;
} }
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer} button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
a{color:var(--accent)} a{color:var(--accent)}
@@ -768,8 +798,13 @@ function nav(on){ $('#sidebar').classList.toggle('open',on); $('#scrim').hidden=
/* ---------------- state ---------------- */ /* ---------------- state ---------------- */
const S = { const S = {
feeds:[], feed:null, entries:[], total:0, offset:0, feeds:[],
filter:'all', q:'', sel:null, me:null, // Which feed (or place) and which tab were open last time, so a refresh lands back where
// you were instead of jumping to the first feed alphabetically.
feed:(()=>{ try{ return localStorage.getItem('ipx.feed'); }catch{ return null; } })(),
entries:[], total:0, offset:0,
filter:(()=>{ try{ return localStorage.getItem('ipx.filter'); }catch{ return null; } })()||'all',
q:'', sel:null, me:null,
// The item table's order, kept across visits. The server sorts: a list arrives fifty at a time. // The item table's order, kept across visits. The server sorts: a list arrives fifty at a time.
sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })() sort:(()=>{ try{ return JSON.parse(localStorage.getItem('ipx.sort')); }catch{ return null; } })()
||{col:'published',dir:'desc'}, ||{col:'published',dir:'desc'},
@@ -781,7 +816,13 @@ async function loadFeeds(keepSel){
S.feeds = await api('/api/feeds'); S.feeds = await api('/api/feeds');
api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{}); api('/api/settings').then(g=>{globalMax=g.max_new_per_check}).catch(()=>{});
renderFeeds(); renderFeeds();
if(!keepSel && !S.feed && S.feeds.length) selectFeed(S.feeds[0].id); // Land back where you were; a feed you no longer subscribe to, or a first visit, goes to
// All Subscriptions rather than picking one alphabetically. Nothing to land on at all (a
// brand new account) leaves S.feed alone, so the empty state's own message shows instead.
if(!keepSel && S.feeds.length){
const known = S.feed && (VIEWS[S.feed] || S.feeds.some(f=>f.id===S.feed));
selectFeed(known ? S.feed : ':all');
}
} }
// An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the // An OPML can hold dozens of feeds; the ones with something new go first. sort is stable, so the
// server's alphabetical order still holds within each half. // server's alphabetical order still holds within each half.
@@ -866,7 +907,8 @@ function renderFeeds(){
done(); done();
} }
function selectFeed(id){ function selectFeed(id){
S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value=''; S.feed=id; S.offset=0; S.sel=null; S.q=''; $('#epSearch').value='';
try{ localStorage.setItem('ipx.feed',id); }catch{}
renderFeeds(); renderFeed(); loadEntries(); renderFeeds(); renderFeed(); loadEntries();
} }
@@ -952,7 +994,11 @@ function renderFeed(){
showDetail(null); showDetail(null);
$$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a)); $$('#content .acts .btn').forEach(b=>b.onclick=()=>f?feedAction(b.dataset.a,f):allAction(b.dataset.a));
$$('#content .tabs button').forEach(b=>b.onclick=()=>{S.filter=b.dataset.f;S.offset=0;renderFeed();loadEntries()}); $$('#content .tabs button').forEach(b=>b.onclick=()=>{
S.filter=b.dataset.f; S.offset=0;
try{ localStorage.setItem('ipx.filter',S.filter); }catch{}
renderFeed(); loadEntries();
});
if(f) wireFailBanner(box,f); if(f) wireFailBanner(box,f);
} }
@@ -1635,16 +1681,42 @@ async function renderListed(v){
$('#tbRemove').disabled=true; $('#tbRemove').disabled=true;
syncTools(null); syncTools(null);
$('#epSearch').placeholder='Search items…'; $('#epSearch').placeholder='Search items…';
const listening=v===VIEWS[':popular'];
box.innerHTML=` box.innerHTML=`
<div class="fhead slim"> <div class="fhead slim">
<div class="art">${v.icon}</div> <div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2> <div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div> <div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div>
</div> </div>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>`; <div class="childlist" id="popular"><p class="hint">Loading…</p></div>
${listening?`<div class="sub" style="margin:18px 0 8px;font-weight:600;color:var(--fg)">Currently Listening</div>
<div class="childlist" id="listening"><p class="hint">Loading…</p></div>`:''}`;
$('#count').textContent=v.title; $('#count').textContent=v.title;
const n=await listFeeds(v.url); const n=await listFeeds(v.url);
if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`; if(VIEWS[S.feed]===v) $('#count').textContent=`${v.title}: ${n} feed${n===1?'':'s'}`;
if(listening) renderListening();
}
/// Below Popular: episodes you started and have not finished, across every feed you
/// subscribe to. A row resumes the episode in the player bar on click -- a shortcut back to
/// where you left off, not another way to browse.
async function renderListening(){
const box=$('#listening');
let rows=[];
try{ rows=(await api('/api/entries?filter=in_progress&limit=10')).entries||[]; }catch{}
box.innerHTML=rows.length?'':'<p class="hint">Nothing in progress. Episodes you start and do not finish show up here.</p>';
for(const e of rows){
const el=document.createElement('div');
el.className='childrow';
const pct=e.duration?Math.min(100,Math.round(e.position/e.duration*100)):0;
el.innerHTML=artHTML(e.image||feedArt(e.feed_id),e.title||'')+
`<div class="txt"><b>${esc(e.title||'(untitled)')}</b>`+
`<small class="meta">${esc(feedName(e.feed_id))} · ${clock(e.position)} of ${e.duration?clock(e.duration):'?'}</small>`+
`<div class="dlbar live"><i style="width:${pct}%"></i></div></div>`+
`<button class="btn ico primary" title="Resume" aria-label="Resume">${ICON.play}</button>`;
el.onclick=()=>play(e);
box.appendChild(el);
}
} }
// The toolbar acts on whatever is selected: the feed on the left, the item in the table. // The toolbar acts on whatever is selected: the feed on the left, the item in the table.
@@ -1702,6 +1774,11 @@ async function prefsModal(){
const gs = splitEvery(g.every_mins); const gs = splitEvery(g.every_mins);
const admin = !!(S.me&&S.me.admin); const admin = !!(S.me&&S.me.admin);
openModal(`<h3>Settings</h3> openModal(`<h3>Settings</h3>
<div class="field"><label>Theme</label>
<select id="stheme">${THEME_ORDER.map(t=>
`<option value="${t}"${document.documentElement.dataset.theme===t?' selected':''}>${THEMES[t]}</option>`).join('')}</select>
<span class="hint">Auto follows your system's light/dark setting. The same toggle is in
the header, one click at a time; this jumps straight to the one you want.</span></div>
<div class="field"><label>Check feeds every</label> <div class="field"><label>Check feeds every</label>
${admin?`<div class="inline"> ${admin?`<div class="inline">
<input type="number" id="gnum" min="1" max="999" value="${gs.n}"> <input type="number" id="gnum" min="1" max="999" value="${gs.n}">
@@ -1742,6 +1819,7 @@ async function prefsModal(){
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>`:''} <span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>`:''}
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button> <div class="cardacts"><button class="btn ico" onclick="closeModal()" title="${admin?'Cancel':'Close'}" aria-label="${admin?'Cancel':'Close'}">${ICON.close}</button>
${admin?`<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button>`:''}</div>`); ${admin?`<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">${ICON.check}</button>`:''}</div>`);
$('#stheme').onchange=e=>setTheme(e.target.value);
$('#gopml').onclick=opmlModal; $('#gopml').onclick=opmlModal;
if(!admin) return; if(!admin) return;
$('#gusers').onclick=usersModal; $('#gusers').onclick=usersModal;
@@ -1951,14 +2029,18 @@ $('#feedlist').onkeydown=ev=>{
}; };
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open')); $('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
$('#scrim').onclick=()=>nav(false); $('#scrim').onclick=()=>nav(false);
// Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered. // Dark, light, the 2004 Mac app, and following the system. The header button steps through
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app'}; // them and Settings offers the same four as a dropdown; either one sets ipx.theme and both
// read it, so they never disagree. Auto last in the cycle keeps the existing three clicks in
// "steps through dark, light and classic" reaching classic exactly where that test expects.
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app',auto:'Auto (matches your system)'};
const THEME_ORDER=Object.keys(THEMES); const THEME_ORDER=Object.keys(THEMES);
const nextTheme=t=>THEME_ORDER[(THEME_ORDER.indexOf(t)+1)%THEME_ORDER.length]; const nextTheme=t=>THEME_ORDER[(THEME_ORDER.indexOf(t)+1)%THEME_ORDER.length];
function setTheme(t){ function setTheme(t){
if(!THEMES[t]) t='dark'; if(!THEMES[t]) t='dark';
document.documentElement.dataset.theme=t; document.documentElement.dataset.theme=t;
$('#theme').title=`Theme: ${THEMES[t]}. Click for ${THEMES[nextTheme(t)]}`; $('#theme').title=`Theme: ${THEMES[t]}. Click for ${THEMES[nextTheme(t)]}`;
const sel=$('#stheme'); if(sel) sel.value=t;
try{ localStorage.setItem('ipx.theme',t); }catch{} try{ localStorage.setItem('ipx.theme',t); }catch{}
} }
$('#theme').onclick=()=>setTheme(nextTheme(document.documentElement.dataset.theme)); $('#theme').onclick=()=>setTheme(nextTheme(document.documentElement.dataset.theme));