Repository navigation
feat(ui): replace the tab bar with the v3 view shell - #434
Merged
Merged
Conversation
…s restore Obsidian 1.14 holds the PodNotes leaf in a collapsed sidebar deferred. Revealing it loads the view, whose setState restored the saved page and collapsed the player Show player had just expanded. Restoring the saved page now leaves the player as it is; only the user's navigation collapses. Show player also expands only while an episode is loaded, so an expanded flag never waits hidden and pops the player open after a later restore.
Obsidian keeps one scope stack per window and pushes onto the active window's. The player's scope stayed on the window it was pushed in, so a player moved to a popout lost Escape, and Escape typed in a main-window editor collapsed it, read from the popout's stale activeElement. The scope now follows the view's window: it is pushed on mount and on each focus of that window while it is active, and moves when Obsidian migrates the view. Escape collapses only when the key comes from inside the view or its document's body. A keyboard collapse returns focus to the mini player. The test keymap keeps a stack per window, as Obsidian's does.
The full player covered the page, the rail and the first-run block only visually: Tab and Shift+Tab still reached their controls. They are inert while the player is open, and stay mounted with their scroll.
The shell handed components a plain settings object, so the player's Queue no longer followed the queue automation toggle (#108), and a settings import, which replaces the object, left the view on the old one. ShellServices.settings is now a store MainView derives from the plugin store, which the toggle and the import both emit.
Obsidian shows the nearest aria-label as a tooltip wherever the pointer rests, so the rail showed "PodNotes" over empty space and the full player showed "Player" over itself. The rail, the Library rows, the mini player and the full player now take their names from hidden text through aria-labelledby. The names stay the same.
ui.mjs clicked the center of a visible match even when another element, such as the full player, covered it, so the click landed on the cover. click, type, fill and wait now wait until nothing covers the match's center, and fail with "covered by <tag.class>" when it stays covered.
When no editor took the timestamp and the episode note has no path or template, the mini player now says "Set an episode note path and template to capture timestamps". A failed write keeps its own Notice. The mini player also drops the time left once none is left, instead of "1 min left", and fills its play and pause glyphs as the prototype does.
Inside the shell's scroller the legacy podcast grid and the Playlists
grid lose their own padding, so they line up with the page's heading.
The first run's column is capped at 360 px and centered, top-aligned so
the search field stays put as results load under it. Podcasts uses the
layout-grid icon, the prototype's 2x2 grid.
The podcast search fades in only. Its outro attached to the page's key
block and lingered over the next page; an {#if true} wrapper hid that
and goes away.
The Back label, the head title, the page title and the compact Library actions each re-encoded part of one rule over layout and page. headFor in pages.ts now owns that rule and is table-tested. The DOM is unchanged.
Pushing it again on every focus of the view's window lifted it over a modal opened since, such as Settings, so Escape no longer closed the modal after the window lost and regained focus. The scope now goes onto the view window's stack once, when that window is active, and moves only when the view moves to another window.
On a phone, Obsidian's floating navbar covered the mini player in the main area, so Timestamp and play/pause could not be tapped, and a mobile drawer's fade washed out its bottom. The shell's main-area view content now pads by the navbar's height and offset on phones only, since a tablet has no navbar, and the drawer fade is hidden for the PodNotes leaf while the mini player shows.
…roller The browse journey now subscribes to enough podcasts that the Library scrolls. It wheels the Library to the bottom, opens the last podcast, goes Back, and reads where the page's scroller came to rest.
Without a keyring, Obsidian loads stored secrets with a Notice that stays until dismissed, over the top of the right sidebar. Once a test stored a secret, each later reload brought it back over the view, so a click on the full player's Collapse button found it covered, depending on the order the files ran in. The harness now dismisses the warning for the run.
When Show player revealed a deferred view whose saved podcast or playlist had been removed, the shell's fallback to Latest episodes went through goRoot, which collapses, so the user got only the mini player. Removing the open podcast under the full player collapsed it the same way. The fallback is not the user navigating, so it now goes through Navigation.replace, the renamed restore, which keeps the full player.
appendTimestampToEpisodeNote decides that the episode note is not set up, so it now shows the "Set an episode note path and template" Notice itself, next to its failure Notice. The headset button reports it as the mini player's Timestamp did, and the mini player no longer re-derives the rule from settings.
Drop the resize fixture the test never drives, and say in MainView.getState that whether the full player is open is not saved.
Deploying podnotes with
|
| Latest commit: |
4c77bb2
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://c5d6518d.podnotes.pages.dev |
| Branch Preview URL: | https://feat-ui-shell.podnotes.pages.dev |
| function navItem(name: string, nav: string, label: string): UiTarget { | ||
| return { | ||
| name, | ||
| elements: `[...document.querySelectorAll(${q(`${PLAYER_VIEW} ${nav} button.pn-navitem`)})].filter((el) => el.querySelector(".lbl")?.textContent.trim() === ${q(label)})`, |
ShellServices gains a library of the stores the view reads. MainView fills it from the plugin's stores, and the shell, its pages and the player read it from context. Tests seed an isolated library, and a check fails on a view component that imports these stores from src/store.
PodcastGrid takes its cards as children and owns the grid's CSS. The Library page fills it with podcasts and the Playlists page with playlists, so the copied grid rules, the unused playlist path and the shell's padding and scroll overrides for the grid go. Both pages render pixel for pixel as before at 320 and 820 px.
The bar the mini player draws, the compact line or the wide track, is a progressbar named Episode progress, with its time as value text such as 12:34 of 45:00. A screen reader can read progress without opening the full player. The times now come from formatDuration, which formats them as the mini player's own clock did.
The mini player sets --pn-btn instead of overriding each button's size for desktop and phone, and the rail and the head share one spacer rule. Both render pixel for pixel as before.
The Selection type and the played-episode placeholder's export served only the view the shell replaced.
…orts Scan the shell's TypeScript too, match imports from src/store/feeds and the other store modules, and pin the layout each progress check renders.
| $playerState.status === "playing" || $playerState.status === "loading", | ||
| ); | ||
| const duration = $derived($playerState.duration); | ||
| const elapsed = $derived(Math.min($playerState.currentTime, duration)); |
There was a problem hiding this comment.
🟡 Medium PodcastView/MiniPlayer.svelte:17
A restored episode with unknown duration displays 0:00 in the wide mini player and its accessible progress text, even when it has saved playback progress. Math.min(currentTime, duration) clamps that position to zero when duration is zero; only clamp when the duration is positive.
Suggested change
| const elapsed = $derived(Math.min($playerState.currentTime, duration)); | |
| const elapsed = $derived(duration > 0 ? Math.min($playerState.currentTime, duration) : $playerState.currentTime); |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/ui/PodcastView/MiniPlayer.svelte around line 17:
A restored episode with unknown duration displays `0:00` in the wide mini player and its accessible progress text, even when it has saved playback progress. `Math.min(currentTime, duration)` clamps that position to zero when `duration` is zero; only clamp when the duration is positive.
…e shell The merged test from #427 asserted the deleted viewState store; the shell opens the full player through the link's reveal.
…ary guard
A helper with export { queue } from "src/store" let a shell component read the global queue past the guard (V-434b). The guard now reports export ... from, export * and import * as, and pins each form, the verifier's bridge included.
# Conflicts: # tests/e2e/journey-resilience.test.ts
The Local Files mirror comment named the deleted podcast grid, and the transcripts recipe said to click Podcast grid.
chhoumann
added a commit
that referenced
this pull request
Oct 13, 2026
The mini player's Timestamp now goes through CaptureService: ShellServices.captureTimestamp returns the Capture result, and MiniPlayer names the note from its path. #434's captureTimestampToEditorOrNote and its capture helpers in main.ts are gone, IPodNotes exposes `capture` instead, and its two main.ts capture tests now drive capture.timestamp(). The NOTE1 journeys open the Library with showLibrary, as the shell replaced the grid tab.
This was referenced Oct 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The view's three icon tabs (grid, episode list, player) hide where you are and what is playing. The v3 design replaces them with a shell. In a narrow view, pages stack with Back. From 560 px wide, a rail lists the roots. A mini player stays at the bottom while you browse, and the full player opens over the page. This is the first step of the redesign (UI0). It hosts today's pages unchanged; UI1 to UI4 restyle them.
What changed
PodcastView.svelteis now the shell. Its page model and navigation live inpages.tsandNavigation.ts, kept at plugin scope asplugin.navigation, so links, menus and commands can open the full player before the view exists.LibraryPage,EpisodesPage,PlaylistsPageandAddPodcastPage.MiniPlayerandPlayerOverlayrenderPlayerControllerstate only.EpisodePlayermoved to runes and gets its services by context.ShellServices.library, whichMainViewfills from the existing stores. A test fails if one of them imports a library store fromsrc/store.PodcastGridis now the one grid both library pages fill.TopBar, theViewStateenum and the globalviewStateandpodcastViewstores are deleted.What moved where
viewState.set(ViewState.Player)after a playplugin.navigation.expand()in the view,plugin.showPlayer()from outside itJourneys
Scope
obsidian://podnoteschange. The last page lives in the view's workspace state.Tradeoffs
Scope, not a DOM listener. Obsidian closes an open menu before any DOM listener runs and does not mark the event, so a DOM listener would close the menu and the player on one key. The scope is pushed on the stack of the view's own window, because Obsidian keeps one stack per window.Blast Radius
Every user sees the new navigation; the hosted pages look as before. Links, the file menu's Play with PodNotes, the episode menu's Play and Show player now open the full player through
showPlayerorexpand. The bundle grows from 93,104 to 98,760 bytes gzip (+5,656) against master e484498 (with PERF2), 1,240 bytes inside the 100,000 budget.Verification
Master and head, 320 and 820:

Head beside the design at 320, light and dark:


At 820:




Phone (emulated, 390 x 844), before and after the navbar fix, in the main area and the drawer:

First run at 320 and 820:

The same view moves from the right sidebar into the main pane, then into a popout, while an episode plays; Escape in the popout collapses the player: screen recording.

Docs at release
getting-started.mdandpodcasts.md: replace "select Podcast grid, the first of the three buttons", the Episode list button and the Player button with the Library (or the rail in a wide view), Back, the mini player and the full player.podcasts.md: the Queue no longer opens the player when selected; it opens the Queue page. "Move to top of queue" and the other moves also work on the Queue page.playlists.md: the built-in playlists and custom playlists live under Playlists, Queue, Local files and Played in the Library or the rail, not in the podcast grid. Queue stays visible when the automatic queue is off.local_files.md: "The Local Files playlist in the podcast grid" becomes Local files in the Library or the rail.commands.md: Show player also opens the full player when an episode is loaded.mobile.md: the mini player and the full player, in the drawer and in the main area.getting-started.md: podcasts can be added from the view (first run, and Add podcast), not only from Settings.podcast-grid.webp,player.webp,episode-list.webp,mobile-player.webpandadd-podcast.webp.