From 48cf34db48ae1e311c309118d1915ab55c6c7b72 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 08:52:07 +0200 Subject: [PATCH 1/9] refactor(feeds): drop the ignored FeedRepository cache option Feeds are always cached since #468. The constructor still accepted a cache option that nothing read, and eight view tests still passed it. --- src/services/FeedRepository.ts | 7 +------ src/ui/PodcastView/AddPodcastPage.test.ts | 1 - src/ui/PodcastView/EpisodesPage.test.ts | 1 - src/ui/PodcastView/LibraryPage.test.ts | 1 - src/ui/PodcastView/ListPage.test.ts | 1 - src/ui/PodcastView/PodcastView.integration.test.ts | 9 +++------ src/ui/PodcastView/PodcastView.shell.test.ts | 1 - src/ui/PodcastView/index.notes.test.ts | 1 - src/ui/PodcastView/menus.test.ts | 1 - 9 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index 5ac8820c..057f3ffe 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -90,12 +90,7 @@ export class FeedRepository implements Readable> private readonly state = writable>(new Map()); private hydrated = false; - constructor( - private readonly deps: FeedRepositoryDeps & { - /** Ignored: feeds are always cached. Tests of the view still pass it. */ - cache?: unknown; - }, - ) { + constructor(private readonly deps: FeedRepositoryDeps) { // Earlier versions' caches can hold private feed URLs, so they go as soon as // PodNotes loads, not when a feed first loads (on mobile the view may stay closed). try { diff --git a/src/ui/PodcastView/AddPodcastPage.test.ts b/src/ui/PodcastView/AddPodcastPage.test.ts index 0ebc9cd0..39ee8578 100644 --- a/src/ui/PodcastView/AddPodcastPage.test.ts +++ b/src/ui/PodcastView/AddPodcastPage.test.ts @@ -55,7 +55,6 @@ function setUp(secrets = new obsidian.SecretStorage()) { load: loadFeed, resolveUrl: (feed) => resolveFeedUrl(feed, feedUrls), storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); const app = new obsidian.App(); diff --git a/src/ui/PodcastView/EpisodesPage.test.ts b/src/ui/PodcastView/EpisodesPage.test.ts index e1d11df2..c392c29b 100644 --- a/src/ui/PodcastView/EpisodesPage.test.ts +++ b/src/ui/PodcastView/EpisodesPage.test.ts @@ -54,7 +54,6 @@ function renderPage(key: string, feeds: PodcastFeed[] = [show], { online = true : null : feed.url, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); ({ services, dispose } = createTestServices({ feeds: repository })); diff --git a/src/ui/PodcastView/LibraryPage.test.ts b/src/ui/PodcastView/LibraryPage.test.ts index 75ddd445..3eac0869 100644 --- a/src/ui/PodcastView/LibraryPage.test.ts +++ b/src/ui/PodcastView/LibraryPage.test.ts @@ -37,7 +37,6 @@ const repositoryWith = ({ load, resolveUrl, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); const unreachable = () => repositoryWith(); diff --git a/src/ui/PodcastView/ListPage.test.ts b/src/ui/PodcastView/ListPage.test.ts index 3cdffca8..447bba29 100644 --- a/src/ui/PodcastView/ListPage.test.ts +++ b/src/ui/PodcastView/ListPage.test.ts @@ -60,7 +60,6 @@ function renderPage( : null : feed.url, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); ({ services, dispose } = createTestServices({ feeds: repository })); diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 50a8d8e7..a57f5f66 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -129,19 +129,17 @@ let storage = new MemoryStorage(); let unsubscribeFeeds = () => {}; /** Builds the plugin the view reads, with a repository fed by `mockLoad` and `storage`. */ -function usePlugin(fields: Record = {}, cacheEnabled = false) { +function usePlugin(fields: Record = {}) { const feedUrls = new FeedUrlRepository(new obsidian.SecretStorage()); const feeds = new FeedRepository({ load: (url) => mockLoad(url), resolveUrl: (feed) => resolveFeedUrl(feed, feedUrls), storage, - cache: () => ({ enabled: cacheEnabled, ttlHours: 6 }), now: Date.now, }); unsubscribeFeeds(); unsubscribeFeeds = savedFeeds.subscribe((saved) => feeds.setSubscriptions(saved)); plugin.set({ - settings: { feedCache: { enabled: cacheEnabled, ttlHours: 6 } }, ...fields, feeds, player, @@ -158,7 +156,6 @@ async function cacheFeeds(...entries: [PodcastFeed, Episode[]][]) { }, resolveUrl: (saved) => saved.url, storage, - cache: () => ({ enabled: true, ttlHours: 6 }), now: Date.now, }); earlier.setSubscriptions(Object.fromEntries(entries.map(([feed]) => [feed.title, feed]))); @@ -478,7 +475,7 @@ describe("issue #174 feed cache cap regression", () => { beforeEach(async () => { await cacheFeeds([testFeed, fullFeed]); respondByUrl({ [testFeed.url]: fullFeed }); - usePlugin({}, true); + usePlugin(); }); test("opening a show bypasses the truncated cache and loads older episodes", async () => { @@ -578,7 +575,7 @@ describe("issue #174 feed cache cap regression", () => { finished: true, }, }); - usePlugin({}, true); + usePlugin(); renderView(); diff --git a/src/ui/PodcastView/PodcastView.shell.test.ts b/src/ui/PodcastView/PodcastView.shell.test.ts index 6a5f072d..e21802ec 100644 --- a/src/ui/PodcastView/PodcastView.shell.test.ts +++ b/src/ui/PodcastView/PodcastView.shell.test.ts @@ -378,7 +378,6 @@ describe("offline", () => { load, resolveUrl: (saved) => saved.url, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }), })); diff --git a/src/ui/PodcastView/index.notes.test.ts b/src/ui/PodcastView/index.notes.test.ts index ea9e3eab..5fbaa3cb 100644 --- a/src/ui/PodcastView/index.notes.test.ts +++ b/src/ui/PodcastView/index.notes.test.ts @@ -38,7 +38,6 @@ async function openPodcastPage(notes: string[]) { load: async () => feedDocument(show.title, [episode]), resolveUrl: (feed) => feed.url, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); const { services, dispose } = createTestServices({ feeds, library: pluginLibrary }); diff --git a/src/ui/PodcastView/menus.test.ts b/src/ui/PodcastView/menus.test.ts index 5d9cced6..d1a44a53 100644 --- a/src/ui/PodcastView/menus.test.ts +++ b/src/ui/PodcastView/menus.test.ts @@ -432,7 +432,6 @@ describe("a podcast's menu, by how its feed loaded", () => { ? null : feed.url, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); ({ services, dispose } = createTestServices({ library: pluginLibrary, feeds })); From 5eb9ca60cf9563358721716e61d334f4268c059d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 08:54:23 +0200 Subject: [PATCH 2/9] refactor(player): move Next in queue into PlayerController.playNext The headset's Next in queue read the queue store and called play inline in bindMediaSession. PlayerController now owns it, beside play and the natural end's queue advance. --- src/player/PlayerController.test.ts | 37 +++++++++++++++++++++++++++++ src/player/PlayerController.ts | 16 +++++++++++++ src/player/mediaSession.ts | 18 ++------------ 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index 94866121..6677ef2a 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -710,6 +710,43 @@ describe("switching episodes (#353)", () => { }); }); +describe("playNext", () => { + test("plays the next other queued episode and queues the one it leaves last", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 42); + queue.add(A); + queue.add(B); + queue.add(C); + + player.playNext(); + + expect([state().episode?.title, queued(), progress(A)?.time]).toEqual(["B", ["C", "A"], 42]); + }); + + test("with auto-queue off, plays the queue in order and queues nothing", () => { + const { player, state } = setup({ autoQueue: false }); + player.play(A); + queue.add(B); + queue.add(C); + + player.playNext(); + + expect([state().episode?.title, queued()]).toEqual(["B", ["C"]]); + }); + + test("does nothing when the queue holds only the current episode", () => { + const { player, state } = setup(); + player.play(A); + queue.add(A); + + player.playNext(); + + expect([state().episode?.title, queued()]).toEqual(["A", ["A"]]); + }); +}); + describe("a new load shows its own position and length until its metadata loads (#94)", () => { test("after a switch away from a playing episode", () => { saved(B, 300, 1800); diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index b5c106b8..7f354962 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -216,6 +216,22 @@ export class PlayerController { return true; } + /** + * Plays the queue's next episode other than the current one. The episode it leaves + * goes last rather than first, so pressing again moves on through the queue. + */ + playNext(): void { + const outgoing = this.snapshot.episode; + const key = getEpisodeKey(outgoing); + const next = get(queue).episodes.find((episode) => getEpisodeKey(episode) !== key); + if (!next || !this.play(next) || !outgoing) return; + const [first] = get(queue).episodes; + if (first && getEpisodeKey(first) === key) { + queue.remove(first); + queue.add(first); + } + } + /** Continues the current episode without selecting it. */ resume(): void { const { status, episode } = this.snapshot; diff --git a/src/player/mediaSession.ts b/src/player/mediaSession.ts index e9035245..6ff2cba5 100644 --- a/src/player/mediaSession.ts +++ b/src/player/mediaSession.ts @@ -1,5 +1,4 @@ import { get, type Unsubscriber } from "svelte/store"; -import { queue } from "src/store"; import type { Chapter } from "src/types/Chapter"; import type { Episode } from "src/types/Episode"; import type { NextTrackAction, PreviousTrackAction } from "src/types/Headset"; @@ -69,7 +68,7 @@ function positionOf({ currentTime, duration, playbackRate }: PlayerState) { } export function bindMediaSession( - player: Pick, + player: Pick, host: MediaSessionHost, ): Unsubscriber { if (!host.mediaSession) return () => {}; @@ -98,26 +97,13 @@ export function bindMediaSession( if (start !== undefined) player.seek(start); } - function playNextInQueue(): void { - const outgoing = get(player.state).episode; - const next = get(queue).episodes.find( - (episode) => getEpisodeKey(episode) !== getEpisodeKey(outgoing), - ); - if (!next || !player.play(next) || !outgoing) return; - const [first] = get(queue).episodes; - if (first && getEpisodeKey(first) === getEpisodeKey(outgoing)) { - queue.remove(first); - queue.add(first); - } - } - const run: Record void> = { captureTimestamp: () => host.captureTimestamp(), skipBackward: () => host.skipBackward(), skipForward: () => host.skipForward(), previousChapter: () => void seekChapter(-1), nextChapter: () => void seekChapter(1), - nextInQueue: playNextInQueue, + nextInQueue: () => player.playNext(), }; const actions: Array<[MediaSessionAction, MediaSessionActionHandler]> = [ From 7effd01f9e254842ef43d4faa5a4d5f914fdf4ac Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 09:05:46 +0200 Subject: [PATCH 3/9] fix(settings): say an import that could not save yet saves by itself Since #470, PodNotes saves on its own once it can write data.json again. The Notice after an import whose final save failed still told the user to change a setting to save again. --- .../PodNotesSettingsTab.import.test.ts | 32 +++++++++++++++++++ src/ui/settings/PodNotesSettingsTab.ts | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index 75327e29..d3c66c33 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -110,6 +110,38 @@ describe("PodNotesSettingsTab settings import", () => { expect(plugin.saveSettingsStrict).toHaveBeenCalledTimes(2); }); + it("keeps the import and says it saves later when only the final save fails", async () => { + const plugin = { + settings: structuredClone(DEFAULT_SETTINGS), + credentials: { storeValues: vi.fn(() => ({})) }, + saveSettings: vi.fn().mockResolvedValue(undefined), + saveSettingsStrict: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("disk full")), + } as unknown as PodNotes; + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); + vi.spyOn(tab, "update").mockImplementation(() => {}); + const notice = vi.spyOn(obsidian, "Notice"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + await ( + tab as unknown as { + applyImportedSettings: ( + imported: Partial, + ) => Promise; + } + ).applyImportedSettings({ defaultPlaybackRate: 2.3 }); + + expect(plugin.settings.defaultPlaybackRate).toBe(2.3); + expect(notice.mock.calls).toEqual([ + [ + "Imported PodNotes settings, but could not save them yet. PodNotes saves them once it can.", + 10000, + ], + ]); + }); + it("puts back a feed named __proto__ when an import that removed it fails", async () => { const showFeed = { title: "__proto__", diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 78d81b35..f0a4f3e0 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -932,7 +932,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { await this.plugin.saveSettingsStrict(); } catch (error) { new Notice( - "Imported PodNotes settings, but could not save all of them. Change any setting to save again.", + "Imported PodNotes settings, but could not save them yet. PodNotes saves them once it can.", 10000, ); console.error("PodNotes: failed to persist normalized imported settings", error); From 056bdea5059d74b2beb66516b6c496cdc08ef359 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 09:43:31 +0200 Subject: [PATCH 4/9] refactor(ui): co-locate each component's styles and split Deck and Panel out of Player PodcastView.svelte held 1,300 lines of page and component CSS in one global block. Each component now carries its own styles, anchored on its own root class with the `.root :global {}` pattern the shell already used. The shell keeps its tokens, its layout, the Obsidian DOM rules and the classes several components share. Player.svelte loses the deck (title, scrubber, transport, capture bar) to Deck.svelte, and ChaptersPanel and UpNextPanel share their chrome through Panel.svelte. Cover draws a local file's tile, and SearchField takes the first run's large variant, so no page reaches into them for it. Variants of the shared .pn-iconbtn and .pn-btn classes win by specificity instead of by stylesheet order. --- src/ui/PodcastView/AddPodcastPage.svelte | 160 ++++ src/ui/PodcastView/ChaptersPanel.svelte | 72 +- src/ui/PodcastView/Cover.svelte | 47 +- src/ui/PodcastView/Deck.svelte | 480 ++++++++++ src/ui/PodcastView/EpisodeRow.svelte | 223 ++++- src/ui/PodcastView/EpisodesPage.svelte | 122 +++ src/ui/PodcastView/LibraryPage.svelte | 64 ++ src/ui/PodcastView/MiniPlayer.svelte | 204 ++-- src/ui/PodcastView/Panel.svelte | 92 ++ src/ui/PodcastView/Player.svelte | 913 +++++------------- src/ui/PodcastView/PlaylistsPage.svelte | 82 ++ src/ui/PodcastView/PodcastView.svelte | 1094 +++------------------- src/ui/PodcastView/QueueList.svelte | 29 + src/ui/PodcastView/QueueRow.svelte | 20 + src/ui/PodcastView/Rail.svelte | 54 +- src/ui/PodcastView/Scrubber.svelte | 80 +- src/ui/PodcastView/SearchField.svelte | 89 +- src/ui/PodcastView/UpNextPanel.svelte | 53 +- 18 files changed, 2040 insertions(+), 1838 deletions(-) create mode 100644 src/ui/PodcastView/Deck.svelte create mode 100644 src/ui/PodcastView/Panel.svelte diff --git a/src/ui/PodcastView/AddPodcastPage.svelte b/src/ui/PodcastView/AddPodcastPage.svelte index ad485d86..282bbd0b 100644 --- a/src/ui/PodcastView/AddPodcastPage.svelte +++ b/src/ui/PodcastView/AddPodcastPage.svelte @@ -67,6 +67,7 @@ value={$search.query} placeholder="Search or paste a link" autofocus + large oninput={(value) => { search.set(value); if (value.trim()) navigation.go({ kind: "add" }); @@ -108,3 +109,162 @@ {/each} {/if} + + diff --git a/src/ui/PodcastView/ChaptersPanel.svelte b/src/ui/PodcastView/ChaptersPanel.svelte index 8c7859b7..3ed47a1e 100644 --- a/src/ui/PodcastView/ChaptersPanel.svelte +++ b/src/ui/PodcastView/ChaptersPanel.svelte @@ -2,6 +2,7 @@ import type { Chapter } from "src/types/Chapter"; import { formatDuration } from "src/utility/formatDuration"; import { chapterAt } from "./chapters"; + import Panel from "./Panel.svelte"; let { chapters, @@ -13,7 +14,7 @@ onseek(time: number): void; } = $props(); - let list: HTMLOListElement | undefined = $state(); + let list: HTMLElement | undefined = $state(); const current = $derived(chapterAt(chapters, position)); // Scrolls only the panel: scrollIntoView would also scroll the view and an editor around it. @@ -25,32 +26,25 @@ }); -
-
    - {#each chapters as chapter, index (index)} -
  1. - -
  2. - {/each} -
-
+ + {#each chapters as chapter, index (index)} +
  • + +
  • + {/each} +
    diff --git a/src/ui/PodcastView/Deck.svelte b/src/ui/PodcastView/Deck.svelte new file mode 100644 index 00000000..9d2a1bcc --- /dev/null +++ b/src/ui/PodcastView/Deck.svelte @@ -0,0 +1,480 @@ + + +
    +
    +

    {episode.title}

    + {#if sourcePage} + {@const page = sourcePage} + + {:else} + {source} + {/if} +
    +
    + player.seek(time)} + /> +
    + {formatDuration(elapsed)} + {#if current >= 0} + + {:else} + + {/if} + -{formatDuration(Math.max(0, duration - elapsed))} +
    +
    +
    + +
    + + + +
    + + {#if volumeOpen} +
    + player.setVolume(Number(event.currentTarget.value))} + /> + {Math.round($playerState.volume * 100)}% +
    + {/if} +
    +
    + + +
    + {@render children?.()} +
    + + diff --git a/src/ui/PodcastView/EpisodeRow.svelte b/src/ui/PodcastView/EpisodeRow.svelte index d6ac9bd7..734aa1cd 100644 --- a/src/ui/PodcastView/EpisodeRow.svelte +++ b/src/ui/PodcastView/EpisodeRow.svelte @@ -72,10 +72,8 @@ onclick={onplay} oncontextmenu={menu} > - {#if row.tile} - - {:else if cover} - + {#if cover} + {/if} {episode.title} @@ -119,3 +117,220 @@ > {/if} + + diff --git a/src/ui/PodcastView/EpisodesPage.svelte b/src/ui/PodcastView/EpisodesPage.svelte index 76373262..c38547cc 100644 --- a/src/ui/PodcastView/EpisodesPage.svelte +++ b/src/ui/PodcastView/EpisodesPage.svelte @@ -120,3 +120,125 @@ {query || $hidePlayedEpisodes ? "No episodes match" : "No episodes"} {/if} + + diff --git a/src/ui/PodcastView/LibraryPage.svelte b/src/ui/PodcastView/LibraryPage.svelte index f428d9fd..18a9430d 100644 --- a/src/ui/PodcastView/LibraryPage.svelte +++ b/src/ui/PodcastView/LibraryPage.svelte @@ -48,3 +48,67 @@ {/each} + + diff --git a/src/ui/PodcastView/MiniPlayer.svelte b/src/ui/PodcastView/MiniPlayer.svelte index 27872e0f..4e79238e 100644 --- a/src/ui/PodcastView/MiniPlayer.svelte +++ b/src/ui/PodcastView/MiniPlayer.svelte @@ -129,117 +129,131 @@ padding: 0 var(--size-4-2); } - .pn-mini-prog { - position: absolute; - left: 0; - right: 0; - top: -1px; - height: 2px; - } + .pn-mini :global { + .pn-mini-prog { + position: absolute; + left: 0; + right: 0; + top: -1px; + height: 2px; + } - .pn-mini-prog span, - .pn-mini-track span { - display: block; - height: 100%; - background: var(--interactive-accent); - } + .pn-mini-prog span, + .pn-mini-track span { + display: block; + height: 100%; + background: var(--interactive-accent); + } - .pn-mini-open { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: var(--size-4-3); - height: auto; - padding: var(--size-4-1); - border: 0; - border-radius: var(--radius-s); - background: none; - box-shadow: none; - color: var(--text-normal); - font-size: var(--font-ui-small); - text-align: left; - cursor: var(--cursor); - } + .pn-mini-open { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: var(--size-4-3); + height: auto; + padding: var(--size-4-1); + border: 0; + border-radius: var(--radius-s); + background: none; + box-shadow: none; + color: var(--text-normal); + font-size: var(--font-ui-small); + text-align: left; + cursor: var(--cursor); + } - .pn-mini-open:hover { - background: var(--background-modifier-hover); - } + .pn-mini-open:hover { + background: var(--background-modifier-hover); + } - .is-wide .pn-mini-open { - flex: 0 1 300px; - } + .pn-mini-cover { + flex: none; + width: 36px; + height: 36px; + border-radius: 5px; + object-fit: cover; + } - .pn-mini-cover { - flex: none; - width: 36px; - height: 36px; - border-radius: 5px; - object-fit: cover; - } + .pn-mini-txt { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } - .is-wide .pn-mini-cover, - :global(.is-phone) .pn-mini-cover { - width: 44px; - height: 44px; - } + .pn-mini-t { + font-weight: var(--font-medium); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - .pn-mini-txt { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - } + .pn-mini-s { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; + } - .pn-mini-t { - font-weight: var(--font-medium); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + .pn-mini-mid { + flex: 1; + display: flex; + align-items: center; + gap: var(--size-4-2); + color: var(--text-muted); + font-size: var(--font-ui-smaller); + font-variant-numeric: tabular-nums; + } - .pn-mini-s { - color: var(--text-muted); - font-size: var(--font-ui-smaller); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-variant-numeric: tabular-nums; - } + .pn-mini-track { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--background-modifier-border-hover); + overflow: hidden; + } - .pn-mini-mid { - flex: 1; - display: flex; - align-items: center; - gap: var(--size-4-2); - color: var(--text-muted); - font-size: var(--font-ui-smaller); - font-variant-numeric: tabular-nums; - } + button.pn-iconbtn { + opacity: 1; + color: var(--text-normal); + } - .pn-mini-track { - flex: 1; - height: 4px; - border-radius: 2px; - background: var(--background-modifier-border-hover); - overflow: hidden; - } + button.pn-mini-capture { + color: var(--text-accent); + } - .pn-mini .pn-iconbtn { - opacity: 1; - color: var(--text-normal); - } + .pn-mini-toggle svg { + fill: currentColor; + } - .pn-mini .pn-mini-capture { - color: var(--text-accent); + .pn-btn-icon { + display: flex; + --icon-size: var(--icon-s); + --icon-stroke: var(--icon-s-stroke-width); + } } - .pn-mini-toggle :global(svg) { - fill: currentColor; + .pn-mini.is-wide :global { + .pn-mini-open { + flex: 0 1 300px; + } + + .pn-mini-cover { + width: 44px; + height: 44px; + } + + button.pn-btn { + height: 30px; + } } - .is-wide .pn-btn { - height: 30px; + :global(.is-phone) .pn-mini :global(.pn-mini-cover) { + width: 44px; + height: 44px; } diff --git a/src/ui/PodcastView/Panel.svelte b/src/ui/PodcastView/Panel.svelte new file mode 100644 index 00000000..675faf22 --- /dev/null +++ b/src/ui/PodcastView/Panel.svelte @@ -0,0 +1,92 @@ + + +
    + {#if head} +
    {@render head()}
    + {/if} + {#if list} +
      {@render children()}
    + {:else} +
    {@render children()}
    + {/if} +
    + + diff --git a/src/ui/PodcastView/Player.svelte b/src/ui/PodcastView/Player.svelte index 23748738..aad4a531 100644 --- a/src/ui/PodcastView/Player.svelte +++ b/src/ui/PodcastView/Player.svelte @@ -2,42 +2,31 @@ import { tick } from "svelte"; import type { Chapter } from "src/types/Chapter"; import type { Episode } from "src/types/Episode"; - import { formatDuration } from "src/utility/formatDuration"; - import { isLocalFile } from "src/utility/isLocalFile"; - import { ownRecord } from "src/utility/ownRecord"; - import { normalizeSkipLength } from "src/utility/skipLength"; import { icon } from "../obsidian/icon"; - import { cachedChapters, chapterAt, loadChapters } from "./chapters"; + import { cachedChapters, loadChapters } from "./chapters"; import ChaptersPanel from "./ChaptersPanel.svelte"; import { useServices } from "./context"; - import { sourceOf } from "./episodeRow"; + import Deck from "./Deck.svelte"; import type { Layout } from "./layout"; - import { anchorOf, episodeMenu, noteEntry, rateMenu, showMenu } from "./menus"; - import type { Page } from "./pages"; - import Scrubber from "./Scrubber.svelte"; + import { anchorOf, episodeMenu, showMenu } from "./menus"; import UpNextPanel from "./UpNextPanel.svelte"; - type Panel = "chapters" | "queue"; + type PanelKind = "chapters" | "queue"; let { viewRoot, layout }: { viewRoot: HTMLElement; layout: Layout } = $props(); const services = useServices(); - const { api, player, navigation, settings, library, rowHasNote, captureTimestamp, scope } = - services; + const { player, navigation, scope } = services; const playerState = player.state; - const { savedFeeds } = library; const id = $props.id(); let section: HTMLElement | undefined = $state(); - let volumeButton: HTMLButtonElement | undefined = $state(); + let deck: Deck | undefined = $state(); let videoFrame: HTMLElement | undefined = $state(); let inMainWindow = $state(true); let chapters: readonly Chapter[] = $state([]); /** The panel the user chose. None shows the artwork at compact widths. */ - let panel = $state(); - let volumeOpen = $state(false); + let panel = $state(); let failedArtworkUrl = $state(""); - let added = $state(false); - let addedTimer: number | undefined; const episode = $derived($playerState.episode as Episode); const wide = $derived(layout === "wide"); @@ -46,31 +35,14 @@ ); const failed = $derived($playerState.status === "error"); const video = $derived($playerState.mediaType === "video"); - const duration = $derived($playerState.duration); - const elapsed = $derived(Math.min($playerState.currentTime, duration)); - const panels = $derived(chapters.length > 0 ? ["chapters", "queue"] : ["queue"]); + const elapsed = $derived(Math.min($playerState.currentTime, $playerState.duration)); + const panels = $derived(chapters.length > 0 ? ["chapters", "queue"] : ["queue"]); const shown = $derived.by(() => { // At compact widths a media error takes the stage, so no panel shows meanwhile. if (failed && !wide) return undefined; if (panel && panels.includes(panel)) return panel; return wide ? panels[0] : undefined; }); - const current = $derived(chapterAt(chapters, elapsed)); - const back = $derived(normalizeSkipLength($settings.skipBackwardLength)); - const forward = $derived(normalizeSkipLength($settings.skipForwardLength)); - const source = $derived(sourceOf(episode)); - const sourcePage = $derived( - isLocalFile(episode) - ? { kind: "local" } - : ownRecord($savedFeeds)[episode.podcastName] - ? { kind: "feed", key: episode.podcastName } - : undefined, - ); - const note = $derived.by(() => { - // A note made or removed since, also by a capture, changes the store. - void $rowHasNote; - return noteEntry(episode, services); - }); const chaptersUrl = $derived(episode.chaptersUrl); $effect.pre(() => { @@ -85,18 +57,11 @@ return () => (latest = false); }); - $effect(() => { - if (failed) volumeOpen = false; - }); - // The view's own scope gets the keys typed while the view's leaf is active, which focus // or a press in the view makes it. A menu or modal over the player takes Escape first. $effect(() => { const escape = scope.register(null, "Escape", () => { - if (volumeOpen) { - volumeOpen = false; - volumeButton?.focus(); - } else void collapse(); + if (!deck?.closeVolume()) void collapse(); return false; }); const space = scope.register(null, " ", (evt) => { @@ -135,22 +100,6 @@ } } - function closeOutside(popover: HTMLElement) { - const close = (event: PointerEvent) => { - const target = event.target as Node; - if (!popover.contains(target) && !volumeButton?.contains(target)) volumeOpen = false; - }; - popover.doc.addEventListener("pointerdown", close, true); - return () => popover.doc.removeEventListener("pointerdown", close, true); - } - - async function capture() { - if (!(await captureTimestamp())) return; - added = true; - window.clearTimeout(addedTimer); - addedTimer = window.setTimeout(() => (added = false), 1200); - } - function fullscreen() { // iOS fullscreens only the media element itself. const video = videoFrame?.querySelector("video") as @@ -161,7 +110,7 @@ } -{#snippet panelView(kind: Panel)} +{#snippet panelView(kind: PanelKind)} {#if kind === "chapters"} (panel = !tabs && shown === kind ? undefined : kind)} - >{kind === "chapters" ? "Chapters" : "Up next"} - {/each} +{#snippet tabs()} +
    + {#each panels as kind (kind)} + + {/each} +
    +{/snippet} + +{#snippet switcher()} +
    + {#each panels as kind (kind)} + + {/each} +
    {/snippet}
    @@ -263,138 +227,15 @@ > {/if} -
    -
    -

    {episode.title}

    - {#if sourcePage} - {@const page = sourcePage} - - {:else} - {source} - {/if} -
    -
    - player.seek(time)} - /> -
    - {formatDuration(elapsed)} - {#if current >= 0} - - {:else} - - {/if} - -{formatDuration(Math.max(0, duration - elapsed))} -
    -
    -
    - -
    - - - -
    - - {#if volumeOpen} -
    - - player.setVolume(Number(event.currentTarget.value))} - /> - {Math.round($playerState.volume * 100)}% -
    - {/if} -
    -
    - - -
    + (panel = "chapters")}> {#if !wide} -
    - {@render choices(false)} -
    + {@render switcher()} {/if} -
    + {#if wide && shown}
    -
    - {@render choices(true)} -
    + {@render tabs()}
    diff --git a/src/ui/PodcastView/PlaylistsPage.svelte b/src/ui/PodcastView/PlaylistsPage.svelte index 91915579..ee41dbb7 100644 --- a/src/ui/PodcastView/PlaylistsPage.svelte +++ b/src/ui/PodcastView/PlaylistsPage.svelte @@ -45,3 +45,85 @@ New playlist
    + + diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index ed393263..d3af00aa 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -348,140 +348,145 @@ --pn-skip: 48px; } - .pn-body { - position: relative; - flex: 1; - min-height: 0; - display: flex; - } + .podcast-view :global { + .pn-body { + position: relative; + flex: 1; + min-height: 0; + display: flex; + } - .pn-body.pn-split { - display: grid; - grid-template-columns: 216px minmax(0, 1fr); - } + .pn-body.pn-split { + display: grid; + grid-template-columns: 216px minmax(0, 1fr); + } - .pn-main { - position: relative; - flex: 1; - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; - } + .pn-main { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + } - .pn-split .pn-main { - --pn-bg: var(--background-primary); - background: var(--pn-bg); - } + .pn-split .pn-main { + --pn-bg: var(--background-primary); + background: var(--pn-bg); + } - .pn-strip { - flex: none; - display: flex; - align-items: center; - gap: var(--size-4-2); - height: 32px; - padding: 0 var(--pn-pad); - background: var(--background-modifier-border); - color: var(--text-muted); - font-size: var(--font-ui-smaller); - font-weight: var(--font-medium); - } + .pn-strip { + flex: none; + display: flex; + align-items: center; + gap: var(--size-4-2); + height: 32px; + padding: 0 var(--pn-pad); + background: var(--background-modifier-border); + color: var(--text-muted); + font-size: var(--font-ui-smaller); + font-weight: var(--font-medium); + } - .pn-strip-icon { - display: flex; - --icon-size: 14px; - } + .pn-strip-icon { + display: flex; + --icon-size: 14px; + } - .pn-head { - flex: none; - display: flex; - align-items: center; - gap: var(--size-2-2); - height: var(--pn-head); - padding: 0 var(--size-4-2) 0 var(--size-4-1); - } + .pn-head { + flex: none; + display: flex; + align-items: center; + gap: var(--size-2-2); + height: var(--pn-head); + padding: 0 var(--size-4-2) 0 var(--size-4-1); + } - .is-wide .pn-head { - padding: 0 var(--size-4-4) 0 var(--size-4-3); - } + .pn-head-title { + margin: 0 0 0 var(--size-4-2); + font-size: var(--font-ui-medium); + line-height: 1.2; + font-weight: var(--font-semibold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - .pn-split .pn-head { - height: 60px; - padding-top: var(--size-4-1); - } + .pn-back-icon { + display: flex; + flex: none; + } - .pn-head-title { - margin: 0 0 0 var(--size-4-2); - font-size: var(--font-ui-medium); - line-height: 1.2; - font-weight: var(--font-semibold); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + .pn-back-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - .is-wide .pn-head-title { - margin-left: var(--size-4-3); - font-size: var(--font-ui-large); - letter-spacing: -0.01em; - } + .pn-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 var(--pn-pad) var(--size-4-6); + } - .pn-back-icon { - display: flex; - flex: none; - } + .pn-title { + display: flex; + align-items: baseline; + gap: var(--size-4-2); + min-width: 0; + margin: var(--size-4-1) 0 var(--size-4-3); + } - .pn-back-label { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + .pn-title h1 { + margin: 0; + font-size: var(--font-ui-large); + font-weight: var(--font-semibold); + line-height: 1.2; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .pn-first-run { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } - .pn-scroll { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 0 var(--pn-pad) var(--size-4-6); + .pn-first-run .pn-first-settings { + position: absolute; + top: var(--size-4-1); + right: var(--size-4-2); + } } /* Without the mini player, a page ends under Obsidian's status bar. */ - .podcast-view:not(:has(> :global(.pn-mini))) .pn-scroll { + .podcast-view:not(:has(> :global(.pn-mini))) :global(.pn-scroll) { padding-bottom: calc(var(--size-4-6) + var(--pn-status-bar)); } - .pn-title { - display: flex; - align-items: baseline; - gap: var(--size-4-2); - min-width: 0; - margin: var(--size-4-1) 0 var(--size-4-3); - } - - .pn-title h1 { - margin: 0; - font-size: var(--font-ui-large); - font-weight: var(--font-semibold); - line-height: 1.2; - letter-spacing: -0.01em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + .is-wide :global { + .pn-head { + padding: 0 var(--size-4-4) 0 var(--size-4-3); + } - .pn-first-run { - position: relative; - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - } + .pn-split .pn-head { + height: 60px; + padding-top: var(--size-4-1); + } - .pn-first-run .pn-first-settings { - position: absolute; - top: var(--size-4-1); - right: var(--size-4-2); + .pn-head-title { + margin-left: var(--size-4-3); + font-size: var(--font-ui-large); + letter-spacing: -0.01em; + } } + /* Classes that several of the view's components share. */ .podcast-view :global { .pn-spacer { flex: 1; @@ -527,6 +532,11 @@ cursor: default; } + .pn-iconbtn[aria-pressed="true"] { + color: var(--icon-color-active); + opacity: 1; + } + .pn-btn { display: inline-flex; align-items: center; @@ -551,12 +561,6 @@ background: var(--interactive-accent-hover); } - .pn-btn-icon { - display: flex; - --icon-size: var(--icon-s); - --icon-stroke: var(--icon-s-stroke-width); - } - .pn-nav { display: flex; flex-direction: column; @@ -663,158 +667,10 @@ padding: 0 var(--size-4-2) 0 var(--size-2-1); } - .pn-alt { - gap: var(--size-4-2); - padding: 0 var(--size-4-2); - } - - .pn-alt > span { - display: flex; - --icon-size: var(--icon-s); - --icon-stroke: var(--icon-s-stroke-width); - } - - .pn-alts { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--size-2-2); - margin-top: var(--size-4-4); - } - - .pn-scroll > .pn-alts { - align-items: flex-start; - margin-top: var(--size-4-2); - } - - .pn-search { - position: relative; - flex: 1; - min-width: 0; - } - - .pn-search-icon { - position: absolute; - left: var(--size-4-2); - top: 50%; - transform: translateY(-50%); - display: flex; - color: var(--text-muted); - pointer-events: none; - --icon-size: var(--icon-xs); - --icon-stroke: var(--icon-xs-stroke-width); - } - - .pn-input { - width: 100%; - padding: 0 28px; - } - - .pn-input::placeholder { - color: var(--text-muted); - } - - .pn-input::-webkit-search-cancel-button { - display: none; - } - - .pn-clear { - position: absolute; - right: 2px; - top: 50%; - transform: translateY(-50%); - width: 24px; - height: 24px; - } - .pn-head > .pn-search:first-child { margin-left: var(--size-4-3); } - .pn-cover { - display: block; - flex: none; - width: 100%; - aspect-ratio: 1; - border-radius: 6px; - object-fit: cover; - } - - .pn-cover.is-text { - display: flex; - align-items: flex-end; - overflow: hidden; - background: var(--background-modifier-border); - color: var(--text-muted); - font-weight: var(--font-bold); - line-height: 1; - text-align: left; - overflow-wrap: anywhere; - container-type: inline-size; - } - - /* cqw is the cover's width. A percentage padding on the cover is its row's. */ - .pn-cover.is-text > span { - padding: 10cqw; - font-size: 12cqw; - } - - .pn-empty { - flex: 1; - min-height: 0; - overflow-y: auto; - display: flex; - flex-direction: column; - align-items: center; - padding: var(--size-4-6) var(--pn-pad) 72px; - text-align: center; - } - - /* Centered, yet scrollable from its top when the view is short. */ - .pn-empty > :first-child { - margin-top: auto; - } - - .pn-empty > :last-child { - margin-bottom: auto; - } - - .pn-empty-mark { - display: flex; - align-items: center; - justify-content: center; - width: 56px; - height: 56px; - margin-bottom: var(--size-4-4); - border-radius: 16px; - background: hsla(var(--interactive-accent-hsl), 0.14); - color: var(--text-accent); - --icon-size: 28px; - --icon-stroke: 1.75px; - } - - .pn-empty h1 { - margin: 0 0 var(--size-4-4); - font-size: var(--font-ui-large); - font-weight: var(--font-semibold); - letter-spacing: -0.01em; - } - - .pn-empty .pn-search { - flex: none; - width: 100%; - max-width: 360px; - } - - .pn-empty .pn-input { - height: 36px; - padding-left: 32px; - } - - .pn-empty .pn-search-icon { - left: 10px; - } - .pn-count { margin: var(--size-2-2) 0 var(--size-4-2); color: var(--text-muted); @@ -837,663 +693,41 @@ --icon-stroke: 1.5px; } - .pn-panel { - width: 100%; - height: 100%; - min-height: 0; - display: flex; - flex-direction: column; - } - - .pn-panel-scroll { - flex: 1; - min-height: 0; + .pn-list { display: flex; flex-direction: column; - overflow-y: auto; - padding: var(--size-4-2) 0 var(--size-4-4); - mask-image: linear-gradient( - to bottom, - transparent, - #000 12px, - #000 calc(100% - 20px), - transparent - ); - } - - .pn-panel-scroll > .pn-panel-empty { - margin: auto 0; + margin: 0 calc(-1 * var(--size-4-2)); } - .pn-panel-head { - flex: none; + .pn-filetile { display: flex; align-items: center; - gap: var(--size-4-2); - min-height: 32px; - padding-bottom: var(--size-2-2); + justify-content: center; + background: var(--background-modifier-border); color: var(--text-muted); - font-size: var(--font-ui-smaller); - font-weight: var(--font-medium); - white-space: nowrap; + --icon-size: 18px; } - .pn-textbtn { - height: auto; - padding: var(--size-2-2) var(--size-4-2); - border-radius: var(--radius-s); - background: none; + .pn-iconbtn:focus-visible, + .pn-btn:focus-visible, + .pn-ep-main:focus-visible, + .pn-gridcard:focus-visible, + .pn-navitem:focus-visible, + .pn-plrow:focus-visible, + .pn-back:focus-visible, + .pn-alt:focus-visible, + .pn-result:focus-visible, + .pn-railsearch:focus-visible, + .pn-mini-open:focus-visible, + .pn-textbtn:focus-visible, + .pn-player button:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 1px; box-shadow: none; - color: var(--text-accent); - font-size: var(--font-ui-small); - font-weight: var(--font-medium); } - - .pn-textbtn:hover { - background: var(--background-modifier-hover); - } - - /* Obsidian's buttons keep their text on one line; these wrap their titles. */ - .pn-result, - .pn-ep-main, - .pn-gridcard { - white-space: normal; - } - - .pn-result, - .pn-ep-main { - display: flex; - gap: var(--size-4-3); - height: auto; - border: 0; - border-radius: var(--radius-s); - background: none; - box-shadow: none; - color: var(--text-normal); - font-size: var(--font-ui-small); - text-align: left; - cursor: var(--cursor); - } - - .pn-result { - align-items: center; - width: 100%; - padding: var(--size-4-2); - } - - .pn-result:hover { - background: var(--background-modifier-hover); - } - - .pn-result .pn-cover { - width: 44px; - } - - .pn-result .txt { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - } - - .pn-result .t, - .pn-ep-title { - font-weight: var(--font-medium); - line-height: 1.35; - } - - /* Long titles take two lines and break anywhere, never under a button beside them. */ - .pn-result .t, - .pn-ep-title, - .pn-hero h1 { - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - overflow-wrap: anywhere; - } - - .pn-result .s { - color: var(--text-muted); - font-size: var(--font-ui-smaller); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .pn-result .end { - flex: none; - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - color: var(--text-muted); - --icon-size: 18px; - } - - .pn-result .end.added { - color: var(--text-success); - } - - .pn-result:hover .end:not(.added) { - color: var(--text-accent); - } - - .pn-linkchip { - display: inline-flex; - align-items: center; - gap: 4px; - vertical-align: bottom; - --icon-size: 12px; - } - - .pn-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); - gap: var(--size-4-2); - } - - .pn-gridcard { - position: relative; - display: block; - width: 100%; - height: auto; - padding: 0; - border: 0; - border-radius: 8px; - background: none; - box-shadow: none; - cursor: var(--cursor); - transition: transform 120ms ease; - } - - .pn-gridcard .pn-cover { - border-radius: 8px; - } - - .pn-gridcard:hover .pn-cover { - box-shadow: var(--shadow-s); - } - - .pn-gridcard:active { - transform: scale(0.98); - } - - .pn-badge { - position: absolute; - left: 6%; - top: 6%; - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--background-primary); - color: var(--text-error); - box-shadow: var(--shadow-s); - --icon-size: 12px; - --icon-stroke: 2.25px; - } - - .pn-iconbtn[aria-pressed="true"] { - color: var(--icon-color-active); - opacity: 1; - } - - .pn-filter { - position: sticky; - top: 0; - z-index: 2; - display: flex; - align-items: center; - gap: var(--size-2-2); - padding: var(--size-4-2) 0; - margin-bottom: var(--size-2-2); - background: var(--pn-bg); - } - - .pn-list { - display: flex; - flex-direction: column; - margin: 0 calc(-1 * var(--size-4-2)); - } - - .pn-ep { - display: flex; - align-items: flex-start; - border-radius: var(--radius-s); - } - - .pn-ep:hover { - background: var(--background-modifier-hover); - } - - .pn-ep-main { - flex: 1; - min-width: 0; - align-items: flex-start; - padding: var(--size-4-2) 0 var(--size-4-2) var(--size-4-2); - } - - .pn-ep-main .pn-cover { - width: 40px; - margin-top: 1px; - border-radius: 5px; - } - - .pn-ep-text { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; - } - - .pn-ep-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 3px 6px; - color: var(--text-muted); - font-size: var(--font-ui-smaller); - font-variant-numeric: tabular-nums; - --icon-size: 13px; - --icon-stroke: 2px; - } - - .pn-ep-meta .sep { - color: var(--text-faint); - } - - .pn-ep-meta .pod { - min-width: 0; - overflow-wrap: anywhere; - } - - .pn-brk { - flex-basis: 100%; - height: 0; - } - - .pn-brk + .sep { - display: none; - } - - .pn-filetile { - display: flex; - align-items: center; - justify-content: center; - background: var(--background-modifier-border); - color: var(--text-muted); - --icon-size: 18px; - } - - .pn-bar { - width: 32px; - height: 3px; - border-radius: 2px; - background: var(--background-modifier-border-hover); - overflow: hidden; - } - - .pn-bar span { - display: block; - height: 100%; - background: var(--interactive-accent); - } - - .pn-ep.is-played .pn-ep-title { - color: var(--text-muted); - font-weight: var(--font-normal); - } - - .pn-ep.is-current .pn-ep-title, - .pn-ep.is-current .pn-ep-meta { - color: var(--text-accent); - } - - .pn-ep.is-unavailable .pn-ep-main { - opacity: 0.45; - cursor: default; - } - - .pn-ep-more { - margin: var(--size-4-2) var(--size-2-2) 0 0; - opacity: 0.55; - } - - .pn-ep:hover .pn-ep-more, - .pn-ep-more:focus-visible { - opacity: 1; - } - - .pn-queue .pn-ep { - align-items: center; - gap: var(--size-2-2); - } - - .pn-queue .pn-ep-main { - align-items: center; - padding-left: 0; - } - - .pn-queue .pn-ep-main .pn-cover { - margin-top: 0; - } - - .pn-queue .pn-bar { - display: none; - } - - .pn-grip { - cursor: grab; - touch-action: none; - color: var(--text-muted); - opacity: 1; - --icon-size: 16px; - } - - .pn-ep:has(.pn-grip.is-dragging) { - position: relative; - z-index: 2; - background: var(--pn-surface); - box-shadow: var(--shadow-s); - } - - .pn-ep-x { - margin-right: var(--size-2-2); - opacity: 0.55; - } - - .pn-ep:hover .pn-ep-x, - .pn-ep-x:focus-visible { - opacity: 1; - } - - .pn-eq { - display: flex; - align-items: flex-end; - gap: 1.5px; - height: 10px; - } - - .pn-eq i { - width: 2px; - height: 100%; - border-radius: 1px; - background: currentColor; - transform-origin: bottom; - animation: pn-eq 1s ease-in-out infinite; - } - - .pn-eq i:first-child { - animation-delay: -0.2s; - } - - .pn-eq i:last-child { - animation-delay: -0.8s; - } - - .pn-eq.paused i { - animation: none; - transform: scaleY(0.6); - } - - .pn-hero { - display: flex; - align-items: center; - gap: var(--size-4-3); - margin: var(--size-2-2) 0 var(--size-4-2); - } - - .pn-hero .pn-cover { - width: 64px; - border-radius: 8px; - } - - .pn-hero > div { - min-width: 0; - } - - .pn-hero h1 { - margin: 0; - font-size: var(--font-ui-medium); - font-weight: var(--font-semibold); - line-height: 1.3; - } - - .pn-hero .by { - margin-top: 2px; - color: var(--text-muted); - font-size: var(--font-ui-smaller); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .pn-banner { - display: flex; - align-items: flex-start; - gap: var(--size-4-2); - margin: var(--size-4-2) 0; - padding: var(--size-4-2) var(--size-4-2) var(--size-4-2) var(--size-4-3); - border: 1px solid rgba(var(--color-red-rgb), 0.25); - border-radius: var(--radius-m); - background: rgba(var(--color-red-rgb), 0.08); - } - - .pn-banner-icon { - display: flex; - margin-top: 6px; - color: var(--text-error); - --icon-size: var(--icon-s); - } - - .pn-banner .txt { - flex: 1; - min-width: 0; - padding-top: 5px; - } - - .pn-banner .h { - font-weight: var(--font-semibold); - } - - .pn-banner .d { - margin-top: 2px; - color: var(--text-muted); - font-size: var(--font-ui-smaller); - font-family: var(--font-monospace); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .pn-plrow { - display: flex; - align-items: center; - gap: var(--size-4-3); - width: 100%; - height: auto; - padding: var(--size-4-2); - border: 0; - border-radius: var(--radius-s); - background: none; - box-shadow: none; - color: var(--text-normal); - font-size: var(--font-ui-small); - font-weight: inherit; - text-align: left; - cursor: var(--cursor); - } - - .pn-plrow:hover { - background: var(--background-modifier-hover); - } - - .pn-plicon { - display: flex; - align-items: center; - justify-content: center; - flex: none; - width: 44px; - height: 44px; - border-radius: 8px; - background: var(--background-modifier-border); - color: var(--text-muted); - --icon-size: 20px; - } - - .pn-plicon.is-fav { - background: rgba(var(--color-pink-rgb), 0.16); - color: var(--color-pink); - } - - .pn-plrow .txt { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - } - - .pn-plrow .t { - font-weight: var(--font-medium); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .pn-plrow .s { - color: var(--text-muted); - font-size: var(--font-ui-smaller); - } - - .pn-plchevron { - display: flex; - color: var(--text-muted); - --icon-size: 16px; - } - - .pn-plrow.is-new .pn-plicon { - background: none; - border: 1px dashed var(--background-modifier-border-hover); - } - - .pn-plrow.is-new .t { - color: var(--text-muted); - font-weight: var(--font-normal); - } - - .pn-iconbtn:focus-visible, - .pn-btn:focus-visible, - .pn-ep-main:focus-visible, - .pn-gridcard:focus-visible, - .pn-navitem:focus-visible, - .pn-plrow:focus-visible, - .pn-back:focus-visible, - .pn-alt:focus-visible, - .pn-result:focus-visible, - .pn-railsearch:focus-visible, - .pn-mini-open:focus-visible, - .pn-textbtn:focus-visible, - .pn-player button:focus-visible { - outline: 2px solid var(--interactive-accent); - outline-offset: 1px; - box-shadow: none; - } - } - - @keyframes pn-eq { - 0%, - 100% { - transform: scaleY(0.4); - } - 50% { - transform: scaleY(1); - } - } - - @media (prefers-reduced-motion: reduce) { - .podcast-view :global(.pn-eq i) { - animation: none; - } - } - - .is-wide :global { - .pn-player .pn-panel-scroll { - padding: var(--size-4-3) var(--size-4-4) var(--size-4-4); - } - - .pn-player .pn-panel-head { - padding: var(--size-4-2) var(--size-4-4) 0 var(--size-4-6); - } - - .pn-ep-meta { - flex-wrap: nowrap; - white-space: nowrap; - } - - .pn-ep-meta > * { - flex: none; - } - - .pn-ep-meta .pod { - flex: 0 1 auto; - min-width: 24px; - overflow: hidden; - text-overflow: ellipsis; - } - - .pn-brk { - display: none; - } - - .pn-brk + .sep { - display: inline; - } - - .pn-grid { - grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); - gap: var(--size-4-4); - } - - .pn-hero { - gap: var(--size-4-5); - margin: var(--size-4-2) 0 var(--size-4-3); - } - - .pn-hero .pn-cover { - width: 112px; - border-radius: 10px; - } - - .pn-hero h1 { - font-size: var(--font-ui-large); - line-height: 1.25; - letter-spacing: -0.01em; - } - - .pn-hero .by { - margin-top: var(--size-4-1); - font-size: var(--font-ui-small); - } - } + } .is-phone :global { - .pn-panel-head { - min-height: 44px; - } - - .pn-grid { - grid-template-columns: repeat(3, 1fr); - gap: var(--size-4-3); - } - .pn-navitem { gap: var(--size-4-3); padding: 0 var(--size-4-2); @@ -1503,48 +737,6 @@ --icon-size: 20px; } - .pn-input { - height: 44px; - padding-left: 34px; - } - - .pn-search-icon { - left: 12px; - --icon-size: 16px; - } - - .pn-clear { - right: 0; - width: 44px; - height: 44px; - } - - .pn-empty .pn-input { - height: 48px; - padding-left: 36px; - } - - .pn-result .pn-cover { - width: 52px; - } - - .pn-ep-main { - padding-top: var(--size-4-3); - padding-bottom: var(--size-4-3); - } - - .pn-ep-main .pn-cover { - width: 48px; - } - - .pn-ep-more { - margin-top: var(--size-4-1); - } - - .pn-hero .pn-cover { - width: 76px; - } - .pn-btn { height: 44px; } diff --git a/src/ui/PodcastView/QueueList.svelte b/src/ui/PodcastView/QueueList.svelte index 7b46f207..dfe3c58d 100644 --- a/src/ui/PodcastView/QueueList.svelte +++ b/src/ui/PodcastView/QueueList.svelte @@ -136,3 +136,32 @@ {empty}
    {/if} + + diff --git a/src/ui/PodcastView/QueueRow.svelte b/src/ui/PodcastView/QueueRow.svelte index 11256d5c..8c2967e9 100644 --- a/src/ui/PodcastView/QueueRow.svelte +++ b/src/ui/PodcastView/QueueRow.svelte @@ -75,3 +75,23 @@ > {/snippet} + + diff --git a/src/ui/PodcastView/Rail.svelte b/src/ui/PodcastView/Rail.svelte index c0c21682..c7ca4836 100644 --- a/src/ui/PodcastView/Rail.svelte +++ b/src/ui/PodcastView/Rail.svelte @@ -51,33 +51,35 @@ border-right: 1px solid var(--background-modifier-border); } - .pn-railsearch { - display: flex; - align-items: center; - justify-content: flex-start; - gap: var(--size-4-2); - flex: none; - width: 100%; - height: var(--input-height); - padding: 0 var(--size-4-2); - border: 1px solid var(--background-modifier-border); - border-radius: var(--input-radius); - background: var(--background-modifier-form-field); - box-shadow: none; - color: var(--text-muted); - font-size: var(--font-ui-small); - text-align: left; - cursor: var(--cursor); - } + .pn-rail :global { + .pn-railsearch { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--size-4-2); + flex: none; + width: 100%; + height: var(--input-height); + padding: 0 var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--input-radius); + background: var(--background-modifier-form-field); + box-shadow: none; + color: var(--text-muted); + font-size: var(--font-ui-small); + text-align: left; + cursor: var(--cursor); + } - .pn-railsearch:hover { - border-color: var(--background-modifier-border-hover); - color: var(--text-normal); - } + .pn-railsearch:hover { + border-color: var(--background-modifier-border-hover); + color: var(--text-normal); + } - .pn-railsearch-icon { - display: flex; - --icon-size: var(--icon-xs); - --icon-stroke: var(--icon-xs-stroke-width); + .pn-railsearch-icon { + display: flex; + --icon-size: var(--icon-xs); + --icon-stroke: var(--icon-xs-stroke-width); + } } diff --git a/src/ui/PodcastView/Scrubber.svelte b/src/ui/PodcastView/Scrubber.svelte index ce664fc1..24119dcb 100644 --- a/src/ui/PodcastView/Scrubber.svelte +++ b/src/ui/PodcastView/Scrubber.svelte @@ -114,37 +114,39 @@ cursor: default; } - .pn-segs { - display: flex; - gap: 2px; - width: 100%; - height: 4px; - } - - .pn-seg { - flex: 1 1 0; - min-width: 2px; - border-radius: 2px; - background: var(--background-modifier-border-hover); - overflow: hidden; - } - - .pn-seg span { - display: block; - height: 100%; - background: var(--interactive-accent); - } - - .pn-thumb { - position: absolute; - top: 50%; - width: 12px; - height: 12px; - margin: -6px 0 0 -6px; - border-radius: 50%; - background: var(--interactive-accent); - box-shadow: 0 0 0 2px var(--pn-bg); - pointer-events: none; + .pn-scrub :global { + .pn-segs { + display: flex; + gap: 2px; + width: 100%; + height: 4px; + } + + .pn-seg { + flex: 1 1 0; + min-width: 2px; + border-radius: 2px; + background: var(--background-modifier-border-hover); + overflow: hidden; + } + + .pn-seg span { + display: block; + height: 100%; + background: var(--interactive-accent); + } + + .pn-thumb { + position: absolute; + top: 50%; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; + border-radius: 50%; + background: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--pn-bg); + pointer-events: none; + } } :global(.is-phone) .pn-scrub { @@ -152,13 +154,15 @@ margin: -8px 0; } - :global(.is-phone) .pn-segs { - height: 5px; - } + :global(.is-phone) .pn-scrub :global { + .pn-segs { + height: 5px; + } - :global(.is-phone) .pn-thumb { - width: 16px; - height: 16px; - margin: -8px 0 0 -8px; + .pn-thumb { + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + } } diff --git a/src/ui/PodcastView/SearchField.svelte b/src/ui/PodcastView/SearchField.svelte index 568cd38c..3b8561d7 100644 --- a/src/ui/PodcastView/SearchField.svelte +++ b/src/ui/PodcastView/SearchField.svelte @@ -6,17 +6,20 @@ value, placeholder, autofocus = false, + large = false, oninput, }: { value: string; placeholder: string; autofocus?: boolean; + /** The first run's wider, taller field. */ + large?: boolean; oninput(value: string): void; } = $props(); let input: HTMLInputElement; - {/if} diff --git a/src/ui/PodcastView/ChaptersPanel.svelte b/src/ui/PodcastView/ChaptersPanel.svelte index 3ed47a1e..75c54ac2 100644 --- a/src/ui/PodcastView/ChaptersPanel.svelte +++ b/src/ui/PodcastView/ChaptersPanel.svelte @@ -44,7 +44,7 @@ diff --git a/src/ui/PodcastView/QueueRow.svelte b/src/ui/PodcastView/QueueRow.svelte index 8c2967e9..ca89f260 100644 --- a/src/ui/PodcastView/QueueRow.svelte +++ b/src/ui/PodcastView/QueueRow.svelte @@ -45,6 +45,8 @@ openEpisode(services, episode)} onmenu={(at) => showMenu(episodeMenu(episode, services, { place: "list", at }), at)} > diff --git a/src/ui/PodcastView/Rail.svelte b/src/ui/PodcastView/Rail.svelte index c7ca4836..c0c21682 100644 --- a/src/ui/PodcastView/Rail.svelte +++ b/src/ui/PodcastView/Rail.svelte @@ -51,35 +51,33 @@ border-right: 1px solid var(--background-modifier-border); } - .pn-rail :global { - .pn-railsearch { - display: flex; - align-items: center; - justify-content: flex-start; - gap: var(--size-4-2); - flex: none; - width: 100%; - height: var(--input-height); - padding: 0 var(--size-4-2); - border: 1px solid var(--background-modifier-border); - border-radius: var(--input-radius); - background: var(--background-modifier-form-field); - box-shadow: none; - color: var(--text-muted); - font-size: var(--font-ui-small); - text-align: left; - cursor: var(--cursor); - } + .pn-railsearch { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--size-4-2); + flex: none; + width: 100%; + height: var(--input-height); + padding: 0 var(--size-4-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--input-radius); + background: var(--background-modifier-form-field); + box-shadow: none; + color: var(--text-muted); + font-size: var(--font-ui-small); + text-align: left; + cursor: var(--cursor); + } - .pn-railsearch:hover { - border-color: var(--background-modifier-border-hover); - color: var(--text-normal); - } + .pn-railsearch:hover { + border-color: var(--background-modifier-border-hover); + color: var(--text-normal); + } - .pn-railsearch-icon { - display: flex; - --icon-size: var(--icon-xs); - --icon-stroke: var(--icon-xs-stroke-width); - } + .pn-railsearch-icon { + display: flex; + --icon-size: var(--icon-xs); + --icon-stroke: var(--icon-xs-stroke-width); } diff --git a/src/ui/PodcastView/UpNextPanel.svelte b/src/ui/PodcastView/UpNextPanel.svelte index c0b25c06..288cff3d 100644 --- a/src/ui/PodcastView/UpNextPanel.svelte +++ b/src/ui/PodcastView/UpNextPanel.svelte @@ -27,7 +27,7 @@ {/snippet}