Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a30edff248 |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -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
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ipx"
|
name = "ipx"
|
||||||
version = "0.5.3"
|
version = "0.5.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
8
TODO.md
8
TODO.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
33
src/db.rs
33
src/db.rs
@@ -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]
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
100
web/index.html
100
web/index.html
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user