You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PlayerBar is destroyed and recreated on every navigation, because each content view constructs its own instance — 18 views do this, 16 of them via .safeAreaInset. The visible symptom is the album artwork flickering when switching views, but the remount also discards all player-bar state and re-issues network calls that were already made.
Evidence
Measured with a timestamped trace (per docs/common-bug-patterns.md) on a debug build, clicking between sidebar playlists / Home / Search:
BAR appear id=01AA <- new PlayerBar identity per view switch
IMG appear id=1684 hasImage=false
IMG task id=1684 hasImage=false
IMG resolved id=1684 ok=y in 216.5ms
BAR appear id=026B <- switch view -> different id
IMG appear id=3CBC hasImage=false
IMG resolved id=3CBC ok=y in 77.0ms
BAR appear id=0FB2
...
Totals for ~13 view switches:
Signal
Count
Distinct PlayerBar identities
13
PlayerBar mounts
14
Artwork CachedAsyncImage mounts
15
canOpenCurrentAlbum transitions
0
Image loads whose primary URL failed before fallback
10 / 15
Three things follow from this:
The bar remounts per view switch. A new identity every time means new @State, so CachedAsyncImage.image resets to nil (CachedAsyncImage.swift:26), the placeholder renders, and the image fades back in over 0.25s (:42-43). That is the flicker.
Reload is never free, even warm.ImageCache is an actor (ImageCache.swift:8), so even a memory-cache hit (:106) costs an await hop — guaranteeing at least one placeholder frame. Observed 30–216ms.
The remount re-runs work..task(id: currentTitleIdentity) fires prepareCurrentNavigationTargets() on each mount (PlayerBar.swift:78), re-resolving the same artist and album over the network ~13 times for one unchanged track.
Also discarded on every switch: seek-drag position, volume slider state, hover state, cached formatted time strings, and the resolved artist/album targets.
Separately, 10 of 15 loads hit a failing primary thumbnail URL before falling back (SongThumbnailView.failedPrimaryKey), adding a second placeholder cycle. Probably worth its own issue.
Why the current shape causes this
The bar can't simply be hoisted today, and the reason is the actual design defect.
PlayerBar needs to push onto the active view's NavigationStack. Those stacks are private @State navigationPath spread across 10 views (HomeView.swift:10, SearchView.swift:13, LibraryView.swift:57, …). Since no single owner can reach the active path, each view instead builds its own bar just to hand it a navigation action.
That makes PlayerBar's interface shallow: to use a module that conceptually just means "show the player," a caller must know three separate facts and get all three right — construct PlayerBar(), thread a playerBarNavigationAction: through the detail view's init, and apply .playerBarMusicNavigation(path:) to the stack. Eighteen call sites each re-derive it. #439 is simply the site where one of the three was missed, which is what a shallow interface reliably produces.
Apply the deletion test: remove the per-view plumbing and the complexity doesn't vanish — it reappears in 18 places. It isn't earning its keep; it's smeared across callers.
Proposed design
1. Centralize navigation-path ownership. Move the per-view @State navigationPath into one @Observable @MainActor navigation coordinator keyed by route. This generalizes a pattern already in the codebase rather than inventing one: MainWindow already owns pinnedNavigationPaths: [String: NavigationPath] (MainWindow.swift:965) for exactly this reason.
2. Mount one PlayerBar in MainWindow as a .safeAreaInset on the detail column, outside every NavigationStack. It reads the active route's path from the coordinator, so openArtist / openAlbum push onto whichever stack is current.
The seam moves to the coordinator: one place that knows "which stack is active and how do I push onto it," with the bar and the views on either side of it.
Consequences
The bar mounts once for the app's lifetime. Flicker, redundant re-resolution, and lost seek/volume/hover state all go away from a single cause rather than three separate patches.
Callers get leverage: the interface for using the player bar becomes nothing — views stop constructing it.
Maintainers get locality: navigation targeting is fixed in one place instead of 18.
It deletes plumbing rather than adding it. Every playerBarNavigationAction: parameter and .playerBarMusicNavigation(path:) call goes away.
Touches ~20 files and reshapes navigation ownership, so per AGENTS.md it warrants an ADR in docs/adr/ before implementation. Main risks: per-route back-stack behavior (including popsNavigationStackOnSidebarReselect) and the macOS 15 legacy UI path (SimplePlaylistDetailView) both need to keep working.
Happy to write the ADR and the PR if this direction sounds right — flagging first since it's a cross-cutting refactor.
Seeding CachedAsyncImage from a synchronous memory-cache peek would hide the flash without stopping the remounts or the redundant network calls. A synchronous cache read may still be worth doing on its own merits, but it is not a fix for this.
Summary
PlayerBaris destroyed and recreated on every navigation, because each content view constructs its own instance — 18 views do this, 16 of them via.safeAreaInset. The visible symptom is the album artwork flickering when switching views, but the remount also discards all player-bar state and re-issues network calls that were already made.Evidence
Measured with a timestamped trace (per
docs/common-bug-patterns.md) on a debug build, clicking between sidebar playlists / Home / Search:Totals for ~13 view switches:
PlayerBaridentitiesPlayerBarmountsCachedAsyncImagemountscanOpenCurrentAlbumtransitionsThree things follow from this:
@State, soCachedAsyncImage.imageresets tonil(CachedAsyncImage.swift:26), the placeholder renders, and the image fades back in over 0.25s (:42-43). That is the flicker.ImageCacheis anactor(ImageCache.swift:8), so even a memory-cache hit (:106) costs anawaithop — guaranteeing at least one placeholder frame. Observed 30–216ms..task(id: currentTitleIdentity)firesprepareCurrentNavigationTargets()on each mount (PlayerBar.swift:78), re-resolving the same artist and album over the network ~13 times for one unchanged track.Also discarded on every switch: seek-drag position, volume slider state, hover state, cached formatted time strings, and the resolved artist/album targets.
Separately, 10 of 15 loads hit a failing primary thumbnail URL before falling back (
SongThumbnailView.failedPrimaryKey), adding a second placeholder cycle. Probably worth its own issue.Why the current shape causes this
The bar can't simply be hoisted today, and the reason is the actual design defect.
PlayerBarneeds to push onto the active view'sNavigationStack. Those stacks are private@State navigationPathspread across 10 views (HomeView.swift:10,SearchView.swift:13,LibraryView.swift:57, …). Since no single owner can reach the active path, each view instead builds its own bar just to hand it a navigation action.That makes
PlayerBar's interface shallow: to use a module that conceptually just means "show the player," a caller must know three separate facts and get all three right — constructPlayerBar(), thread aplayerBarNavigationAction:through the detail view's init, and apply.playerBarMusicNavigation(path:)to the stack. Eighteen call sites each re-derive it. #439 is simply the site where one of the three was missed, which is what a shallow interface reliably produces.Apply the deletion test: remove the per-view plumbing and the complexity doesn't vanish — it reappears in 18 places. It isn't earning its keep; it's smeared across callers.
Proposed design
1. Centralize navigation-path ownership. Move the per-view
@State navigationPathinto one@Observable @MainActornavigation coordinator keyed by route. This generalizes a pattern already in the codebase rather than inventing one:MainWindowalready ownspinnedNavigationPaths: [String: NavigationPath](MainWindow.swift:965) for exactly this reason.2. Mount one
PlayerBarinMainWindowas a.safeAreaInseton the detail column, outside everyNavigationStack. It reads the active route's path from the coordinator, soopenArtist/openAlbumpush onto whichever stack is current.The seam moves to the coordinator: one place that knows "which stack is active and how do I push onto it," with the bar and the views on either side of it.
Consequences
playerBarNavigationAction:parameter and.playerBarMusicNavigation(path:)call goes away.Scope and risk
Touches ~20 files and reshapes navigation ownership, so per
AGENTS.mdit warrants an ADR indocs/adr/before implementation. Main risks: per-route back-stack behavior (includingpopsNavigationStackOnSidebarReselect) and the macOS 15 legacy UI path (SimplePlaylistDetailView) both need to keep working.Happy to write the ADR and the PR if this direction sounds right — flagging first since it's a cross-cutting refactor.
Related
Not proposed
Seeding
CachedAsyncImagefrom a synchronous memory-cache peek would hide the flash without stopping the remounts or the redundant network calls. A synchronous cache read may still be worth doing on its own merits, but it is not a fix for this.