Skip to content

PlayerBar remounts on every navigation: artwork flicker, redundant network resolves, lost player state #445

Description

@tsibog

Summary

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:

  1. 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.
  2. 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.
  3. 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.
  • It makes fix(player): pass playerBarNavigationAction to sidebar playlist views #439's bug unrepresentable: with no per-view action, there's nothing for a future view to forget to pass.

Scope and risk

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.

Related

Not proposed

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions