Skip to content

feat(ui): add the First run, Add podcast, Library and podcast episodes pages - #447

Merged
chhoumann merged 57 commits into
masterfrom
feat/ui-pages
Oct 13, 2026
Merged

chhoumann merged 57 commits into
masterfrom
feat/ui-pages

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Why

An empty PodNotes view said "No saved podcasts." and searching for a show was only possible in Settings. This was the most common complaint in user feedback (#346). This PR builds the v3 First run, Add podcast, Library and Podcast episodes pages from the design spec and prototype, and deletes the legacy grid, episode list and settings search they replace.

What changed

  • First run: a centered "Add your first podcast" with a focused search field, plus "Play a vault file" and "Import OPML". Typing opens Add podcast with the query and focus kept. Add podcast searches as you type (up to 20 shows, Podcast search returns only 3 results and cannot add from an Apple Podcasts link #344). A pasted RSS URL or Apple Podcasts link resolves to one result with a link chip. One tap adds the show and opens its episodes, and a second tap plays an episode in the full player.
  • Library: list rows and the grid at sidebar width; the grid and "Refresh all feeds" in the wide layout. Cards show the title when artwork fails and carry an error badge when the feed failed. Podcast episodes: hero, filter bar (search, hide played, refresh), and one shared episode row with every SPEC state. Long titles take two lines and never run under the More options button (Very long episode titles break the episode list layout #371). A failed feed shows a banner with Retry, or "Set feed address" for a private feed missing on this device, and the cached episodes stay listed.
  • The episode menu follows SPEC 5.1 (Play or Open player, queue, note, download, Favorites, Add to playlist as a second menu with checks, Mark as played, Copy episode link). A new podcast menu (on the page head and on a card's right-click or long press) holds the podcast note, Set feed address, and Remove podcast, which used to live in Settings. On mobile, the view opens once on a true new install.

Scope

  • The other list pages (Latest, Queue, Local files, Played, playlists) render through the new row but keep today's behavior. Their heads, in-place queue reorder, offline grouping and the remaining menus are UI1b. The player is UI2 (only its queue list moved to the shared row), and the settings tab cleanup is UI3.
  • No data.json shape, IAPI or obsidian://podnotes change. Desktop still opens the view at start when no leaf exists, as before.

Tradeoffs

  • The pages' library writes go through one LibraryActions service (src/services/library.ts). It covers adding and removing a podcast, setting a private feed's address, queue membership and order, Favorites, playlists, and played state. Downloads, OPML import, local files, the hide-played preference and settings writes keep their existing owners. View components read library stores only by context, and the guard test now allows only index.ts. Exposing the store objects instead would have moved the identity rules into every menu.
  • Menus are built as data and shown by one function, so tests read their order and labels without mocking Obsidian's Menu.

Blast Radius

Every user sees the new pages, and Search Podcasts leaves Settings. The private-feed rules moved from the settings search unchanged, with their tests: interning into SecretStorage, namesake refusal, last-reference secret cleanup, and the sync-race guard. The bundle shrinks from 99,768 to 99,412 bytes gzip (-356) against master e3243c4, by deleting the components it replaces.

Verification

  • Checks at HEAD: lint, format:check, typecheck, build, size, test (2,213 tests), test:tooling, docs:build, and test:e2e 120/120 on Obsidian 1.14.4 on a fresh vault. New journeys: first run to playing, a pasted RSS link, a pasted Apple Podcasts link, a feed error with Retry, the episode menu, and the mobile new install. Every new unit test and journey fails on a planted defect.
  • Two review rounds by claude-fable-5-1 and gpt-6-astra. A separate verify pass in real Obsidian covered the note icon after Create note and delete, same-titled episodes, hide played in the player's queue, and the menu at 320 px.
  • Screenshots in real Obsidian 1.14.4 at 320, 820 and an emulated 390 phone, in light and dark. Each image has master on the first row, this PR on the second, and the design prototype on the third.

Sidebar 320, light and dark:
Sidebar light
Sidebar dark

Pane 820, light and dark:
Pane light
Pane dark

Phone 390 (emulated), light and dark:
Phone light
Phone dark

New install with mobile emulation: no view and no data.json, enable PodNotes, the view opens on First run, paste a feed URL, tap the result, tap an episode, and it plays. A reload afterwards keeps the view closed.
New install on mobile
screen recording

Docs at release

  • getting-started, podcasts, faq: add a podcast from the view (First run, the Library's plus button, or the rail's Add podcast), not Settings > Search Podcasts. A pasted RSS URL or Apple Podcasts link works in the same field. Remove a podcast with the podcast menu > Remove podcast. Replace the add-podcast screenshot.
  • settings: drop the Search Podcasts section.
  • podcasts, notes, playlists, local_files: the episode menu's new labels and order (Add to queue, Create note / Open note, Remove download, Add to playlist opens a list of playlists, Mark as played, Copy episode link). The feed note moves to the podcast menu as Create podcast note / Open podcast note. A failed feed shows a banner with Retry or Set feed address.
  • local_files, import_export: Play a vault file and Import OPML on First run.
  • mobile: the view opens once when PodNotes is first enabled.
  • docs/scripts/screenshots.mjs uses the old selectors and the settings search; update it for the new pages.

Closes #346. Refs #344, #371.

Note

Add First run, Add podcast, Library, and Episodes pages to PodNotes UI

  • Replaces the settings-based podcast search with new view pages: First run, Add podcast, Library, and podcast episodes, built from new EpisodeRow, Cover, SearchField, and page components (PodcastView.svelte, AddPodcastPage.svelte, LibraryPage.svelte, EpisodesPage.svelte)
  • Adds a PodcastSearch service that accepts free text, Apple Podcasts URLs, and HTTP/HTTPS feed URLs, with debounced directory search, stale-result suppression, and categorized failures (podcastSearch.ts)
  • Adds createLibraryActions in library.ts for adding, removing, and setting addresses for podcasts, with private-feed secret handling and idempotent queue/favorite/played state
  • iTunes directory results now return a DirectoryShow with feed metadata plus episodeCount from trackCount (iTunesAPIConsumer.ts)
  • Note lookup in createPodcastNote.ts now tries exact template-path and GUID match before title-overlap scanning, with a task-scoped GUID cache; a fresh install activates the podcast view once in main.ts
  • Behavioral Change: settings tab no longer renders the podcast search grid (podcast search moved to the Add podcast page); feed failures now show a sanitized in-page error banner with Retry or Set feed address instead of Obsidian notices; EpisodeListEntry.unavailableReason is removed; IPodNotes implementations must provide feedUrls

Macroscope summarized 1c7ac0a.

screens.mjs places the view at sidebar 320, pane 820 and phone 390,
switches theme, seeds a scene and shoots the five UI1a states, with a
contact sheet beside the prototype's screen. fixtures.mjs gains a
library manifest of eight podcasts, one whose feed answers 404.
The v3 pages change the library only through ShellServices.actions
(src/services/library.ts), which carries the rules PodcastQueryGrid and
spawnEpisodeContextMenu kept to themselves: private-feed interning,
namesake refusal, setting membership instead of toggling it. Episode rows
render one pure model (episodeRow.ts), and the episode and podcast menus
are built as data that one renderer shows (menus.ts), anchored in the
pointer's own document. Head.libraryActions becomes Head.action, so the
wide Library gets Refresh all and a podcast's page its menu button.

The legacy components still run beside these until the pages replace them.

size: 100097 gzip (+1337 before the legacy deletions)
First run is the empty library's page: the mark, "Add your first
podcast", a focused "Search or paste a link" field, and quiet buttons to
play a vault file or import OPML. Its first keystroke opens Add podcast,
whose field sits in the head with the text and the caret kept. Results
name each show's author and episode count; a pasted RSS URL or Apple
Podcasts link resolves to one result with a chip naming its source. A
tap adds the show and opens its episodes; an added show shows a check
and opens. Failures read as fixed lines, never an error's text, and only
the newest input may publish.

Search podcasts leaves the settings tab: PodcastQueryGrid and
PodcastResultCard are deleted, their rules live in LibraryActions and
PodcastSearch, and their tests moved to AddPodcastPage.test.ts and
library.test.ts. The settings tab and First run share pickFile, and the
file menu and the vault media picker share playLocalFile.

size: 98908 gzip (-1189 against the contracts commit)
… menu

The Library's grid is the shell's .pn-grid, sized by the view's own
layout classes: 84 px tiles compact, 108 px wide, three columns on a
phone, with no square backdrop behind rounded art. A tile whose artwork
is missing or fails shows the podcast's title on a plain tile instead of
initials or a blank square, and a podcast whose feed did not load wears
a badge and says so in its label. Right-click or a long press opens the
podcast menu. The wide Library's head refreshes every feed.

PodcastGrid and PodcastGridCard are deleted; the Playlists page uses the
same grid.

size: 98703 gzip (-205)
A podcast's page is headed by its artwork, title and author. A feed that
did not load shows a "Feed didn't load" banner naming its server, never
the address or the error, and keeps the cached episodes under it; Retry
reloads, and a private feed whose address is not on this device offers
"Set feed address" instead. The filter bar searches, hides played
episodes and refreshes. Rows show played, in progress with the time
left, playing or paused, unavailable, downloaded and has note, and say
it in their label. Titles clamp at two lines and the More button sits
beside them, never over them (#371). Tapping a row plays it and opens
the player; the current episode only opens the player. More, right-click
and long press open the episode menu.

Latest, Queue, Local files, Played and a playlist render through the
same page and row. The player's queue uses the row and the menus too.
EpisodeList, EpisodeListItem, EpisodeListHeader, spawnEpisodeContextMenu
and ImageLoader are deleted, with the shell's legacy scroller overrides;
the libraryContext guard now allows only MainView.

size: 98171 gzip (-532)
… time

A new install, a start that found no data.json, opens the view in the
mobile app as it already does on desktop, so the empty view's search is
the first thing a new user meets (#346). PodNotes writes data.json on
that start, so later mobile starts stay dormant as before.

size: 98200 gzip (+29)
The episode row decides "downloaded" from the library it is given, and
an unavailable Played record says so in its row's label, so
downloadedEpisodes.isEpisodeDownloaded and EpisodeListEntry's
unavailableReason have no reader left.

size: 98167 gzip (-33)
# Conflicts:
#	.claude/skills/verify/SKILL.md
#	.claude/skills/verify/fixtures.mjs
The mini player and episode rows say time left the same way, from one
function. Two comments that restated their code go.

size: 98177 gzip (+10)
The merge brought PickerModal from #442, so "Play a vault file" offers
the vault's audio and video files through it instead of a second
FuzzySuggestModal subclass.

size: 99115 gzip (-38 against the merge, 99153; master e3243c4 is 99768)
Episode rows ask whether their episode has a note, and since #442 that
lookup reads every note's frontmatter for its guid. A podcast's page asks
for all its rows at once, which read the vault once per episode. The
notes with a guid are now read once per task and read again in the next,
so a note created or synced since is still found.

size: 99155 gzip (+40)
Obsidian's button style sets white-space: nowrap, which episode rows,
search results and grid cards inherited, so a long title stayed on one
clipped line instead of taking the two-line clamp. Live at 320 px the
#213 Field Notes title now takes two lines ending in an ellipsis beside
More options.

npm run size: 99,109 gzip (master e3243c4: 99,768).
The banner names the failure, keeps the cached episodes and offers Retry
or Set feed address, so the podcast page no longer also raises the old
"Could not load episodes" and private-address Notices.
From the rail the page has nothing before it and stays without Back.
…ecret

An episode opened through an obsidian://podnotes URI for a private feed
that is not subscribed carries the feed's own credential-bearing
address, which the episode link embeds. The menu now offers no link for
such an episode. A subscribed private feed's link keeps its placeholder,
and the URI format is unchanged.

npm run size: 99,118 gzip (master e3243c4: 99,768).
Two episodes of one feed that share a title share a key, so after one
played, tapping the other only opened the player and both rows showed
as playing. A row, Play and Open player now count an episode as current
only when it has the player's key and the same media, as the player's
own reuse rule does. The queue keeps its key identity.

npm run size: 99,135 gzip (master e3243c4: 99,768).
…y feed

The placeholder for such a record has no media or link, so Create note
and Add to Favorites made a note and a favorite of nothing playable. As
on master, its menu now holds only Mark as played or unplayed. Leaving
early for it also drops the menu's other availability checks.

npm run size: 99,135 gzip (master e3243c4: 99,768).
Each row asked getPodcastNote, whose title-overlap scan reads the whole
notes folder for every row without a note, on mount and on each filter
keystroke, and the icon never changed after a note was created or
deleted. Rows now ask findExactPodcastNote, the guid index and the
template's paths only, through a lookup the view replaces when a note
is created, deleted or renamed. The view registers those vault events
once the layout is ready, since the vault reports every file as created
while it loads, and Obsidian removes them when the view closes. Edits
do not refresh rows. The menu and opening a note keep the full lookup.

npm run size: 99,219 gzip (master e3243c4: 99,768).
…d podcast pages

Podcasts are added on the view's Add podcast page and removed from the
podcast menu, Set feed address lives in the podcast page's banner and
menu, and the episode menu uses the new labels. A journey that waited on
the old Updating banner waits on feed status instead, the offline
failure is asserted through the banner, the Library scroll journey has
enough podcasts to scroll the denser grid, and the restored current
episode's row opens the player, where play resumes it.
…eed error with Retry

The fixture server can fail a feed until it is restored, and the iTunes
stub answers lookups, so an Apple Podcasts link resolves offline.
J-ONB-02 splits into a true new install, with no data.json and no
PodNotes leaf, and a later mobile start, which stays closed.
A percentage padding resolves against the containing block, so a 44 px
result cover in a 500 px row took about 48 px of padding a side, grew to
97 px and lost its title. The padding is now 10cqw on the title, the
cover being its inline-size container, and the title sits left as in
the prototype. Rendered in headless Chrome with Obsidian 1.14.4's
app.css at a 500 px view: grid card 94x94, result row 44x44, hero
64x64 and episode row 40x40, each showing its title (before: 97x97,
100x100 and 95x95 with a 0x0 title). The title is now 12% of the cover,
near the prototype's 12.5cqw, so a grid card's reads 11.2 px, not 9 px.

npm run size: 99,221 gzip (master e3243c4: 99,768).
…all recipe

Adding a podcast happens on the view's Add podcast page, removing it in
the podcast menu, and Set feed address in the podcast page's banner or
menu. The New install recipe disables PodNotes, deletes its data.json
and enables it again, optionally under mobile emulation.
The Played page filtered its own order by the search's results, so a
search for "reading" listed a newer "Reading slowly, again and again"
above "Reading". It now lists the search's results in their order, as
master's filterPlayedEntries did.

npm run size: 99,237 gzip (master e3243c4: 99,768).
Master's queue in the full player followed the hide played preference,
as every list did. The rows that replaced it listed every queued
episode. The queue now reads the preference from the library context.

npm run size: 99,257 gzip (master e3243c4: 99,768).
The test rendered the player over untyped services with no actions and
only looked for the item's title, so it passed with a reorder that did
nothing. It now uses the test services, chooses Move up in queue and
checks the queue's order.

npm run size: 99,257 gzip (master e3243c4: 99,768); tests only.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1c7ac0a
Status: ✅  Deploy successful!
Preview URL: https://a95f0221.podnotes.pages.dev
Branch Preview URL: https://feat-ui-pages.podnotes.pages.dev

View logs

Comment thread tests/e2e/journey/actions.ts Fixed
Comment thread tests/e2e/journey/actions.ts Fixed
Comment thread tests/e2e/journey/actions.ts Fixed
Comment thread src/ui/PodcastView/episodeRow.ts Outdated
Comment thread src/ui/PodcastView/index.ts
… copy

A row and its menu matched a download by podcast and title, so a same-titled episode of the same podcast showed the other's download, and its Remove download would delete that file, while playback streamed it. The row, the menu and playback now share one matcher, downloadedCopy.
…ts at it

Rows looked their notes up again only for vault changes, so after a change to Note creation file path a row kept its old state while its menu offered Open note. The field now announces the change through the plugin store, as the queue toggle does, and the view looks again when the note path changes. The deferred-view test now opens and closes its view through load and unload, as Obsidian does.
On a new mobile install the view mounted inside a hidden leaf, so the field's focus call failed and typing did nothing until a tap. An autofocus field now takes focus the first time it has a width, once.
Typing came right after First run showed, so it could reach the window before the field took focus on the next frame. The journeys now wait until the field has focus, as a user waits for the caret, and fail by name when it never does.
Brings in 25ca793, test(e2e): retain runtime errors and recover
interrupted runs (#446), and keeps both sides of every conflict:

- journey/actions.ts: the v3 selectors (.pn-head back button,
  button.pn-gridcard with its failed label, .pn-ep-main/.pn-ep-title,
  button.pn-result) built through #446's jsLiteral and byAttribute.
  ui.removePodcast stays deleted; #447 removes podcasts through the
  podcast menu. failedPodcastCard and shownResults referenced the q
  alias #446 deleted, so they use byAttribute and jsLiteral.
- journey/fixtures.ts: the iTunes stub's search and lookup handler,
  with the results literal written by jsLiteral.
- journey-feeds.test.ts: the empty feed shows no feed error banner and
  raises no new Notice.
- journey-mobile.test.ts: the new-install test checks runtime errors
  with assertNoNewRuntimeErrors, #446's replacement for
  assertNoRuntimeErrors.
- local-files-runtime and marketplace-review-runtime: .pn-ep-title
  with jsLiteral values.
- private-feeds.test.ts: the Library menu path for Set feed address;
  the settings-tab path stays removed.
The head's Back is .pn-back, or .pn-iconbtn on the Add podcast page, and
the harness probe sits outside the head; the label alone names it.
@chhoumann
chhoumann merged commit 4bfb5a7 into master Oct 13, 2026
15 checks passed
@chhoumann
chhoumann deleted the feat/ui-pages branch October 13, 2026 13:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

First-run experience: the empty PodNotes view should help users add a podcast

2 participants