fix(flutter): full-player kebab nav + restore artistAlbums SWR

Two unrelated issues, batched.

Full-player kebab → "Go to album/artist" still crashed with
_debugCheckDuplicatedPageKeys despite the prior onBeforeNavigate
fix. Cause: pop() and push() ran in the same frame, so go_router's
page-key reservation table briefly contained both /now-playing AND
the destination shell-child, tripping the duplicate-key assert.

Refactor: replace onBeforeNavigate with onNavigate(path), where the
host receives the path AFTER the sheet pops and owns the
navigation entirely. The full player wires:
  onNavigate: (path) async {
    await Navigator.of(context).maybePop();
    if (context.mounted) GoRouter.of(context).push(path);
  }
Awaiting the pop guarantees /now-playing is fully gone from the
navigator's page list before the push starts.

Mini player + track rows + everywhere else use the default (no
onNavigate) path that does context.push(path) inline — they're
already inside the ShellRoute, no race possible.

Restored alwaysRefresh: true on artistAlbumsProvider. Dropping it
in the cache-loop fix had a side effect: if drift's cachedAlbums
held only a subset of an artist's albums (user previously visited
just one album by them), the artist detail page rendered that
partial list forever — provider only fetched on empty drift, never
on partial drift. The metadata prefetcher only mass-warms
artistProvider (single row), so re-enabling SWR on artistAlbums
won't recreate the storm.
This commit is contained in:
2026-05-11 23:54:11 -04:00
parent a09b636e1a
commit 5fc04f14b7
4 changed files with 46 additions and 23 deletions
@@ -101,6 +101,14 @@ final artistAlbumsProvider =
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
// SWR: re-fetch the full album list on every visit. Without this,
// a previously-incomplete drift cache (e.g. user had only opened
// one album by this artist, so cachedAlbums had just that row)
// would render forever as a partial list. The prefetcher only
// warms artistProvider (single row), so this provider isn't
// mass-instantiated and the storm risk that motivated dropping
// alwaysRefresh elsewhere doesn't apply here.
alwaysRefresh: true,
tag: 'artistAlbums($artistId)',
);
});
@@ -400,9 +400,14 @@ class _SecondaryControls extends StatelessWidget {
// /now-playing lives outside the ShellRoute. Pushing
// /artists/:id or /albums/:id (both shell-children) on top
// of it would make go_router try to mount a duplicate
// ShellRoute. Pop ourselves first so the destination route
// mounts cleanly inside the existing shell.
onBeforeNavigate: () => Navigator.of(context).maybePop(),
// ShellRoute (the same _debugCheckDuplicatedPageKeys crash
// we hit before with /queue). Await our own pop fully
// before pushing the destination so go_router never sees
// both pages active in the same frame.
onNavigate: (path) async {
await Navigator.of(context).maybePop();
if (context.mounted) GoRouter.of(context).push(path);
},
),
],
);
@@ -11,7 +11,7 @@ class TrackActionsButton extends StatelessWidget {
super.key,
required this.track,
this.hideQueueActions = false,
this.onBeforeNavigate,
this.onNavigate,
});
final TrackRef track;
@@ -20,10 +20,10 @@ class TrackActionsButton extends StatelessWidget {
/// screen where the menu's track IS the currently-playing one.
final bool hideQueueActions;
/// Forwarded to TrackActionsSheet.onBeforeNavigate. Use to dismiss
/// the host route before "Go to album" / "Go to artist" pushes the
/// detail screen.
final void Function()? onBeforeNavigate;
/// Forwarded to TrackActionsSheet.onNavigate. The host receives the
/// destination path AFTER the sheet has popped and is responsible
/// for navigating to it (typically: pop self first, then push).
final Future<void> Function(String path)? onNavigate;
@override
Widget build(BuildContext context) {
@@ -35,7 +35,7 @@ class TrackActionsButton extends StatelessWidget {
context,
track,
hideQueueActions: hideQueueActions,
onBeforeNavigate: onBeforeNavigate,
onNavigate: onNavigate,
),
);
}
@@ -21,25 +21,27 @@ class TrackActionsSheet extends ConsumerWidget {
super.key,
required this.track,
required this.hideQueueActions,
this.onBeforeNavigate,
this.onNavigate,
});
final TrackRef track;
final bool hideQueueActions;
/// Optional hook invoked just before "Go to album" / "Go to artist"
/// pushes the destination route. Lets callers that live OUTSIDE the
/// ShellRoute (e.g. the full player at /now-playing) pop themselves
/// first, so go_router doesn't try to mount a second ShellRoute on
/// top of the active top-level route — the same duplicate-page-key
/// failure mode we hit when /queue lived inside the shell.
final void Function()? onBeforeNavigate;
/// Optional hook for "Go to album" / "Go to artist". Called with
/// the destination route path AFTER the sheet has popped. Lets
/// callers that live OUTSIDE the ShellRoute (e.g. the full player
/// at /now-playing) pop themselves first then push, so go_router
/// doesn't try to mount a second ShellRoute on top of the active
/// top-level route. When null, the sheet does its own
/// context.push, which is correct for surfaces already inside the
/// shell (mini player, track rows on detail screens, etc.).
final Future<void> Function(String path)? onNavigate;
static Future<void> show(
BuildContext context,
TrackRef track, {
bool hideQueueActions = false,
void Function()? onBeforeNavigate,
Future<void> Function(String path)? onNavigate,
}) {
return showModalBottomSheet<void>(
context: context,
@@ -47,7 +49,7 @@ class TrackActionsSheet extends ConsumerWidget {
builder: (_) => TrackActionsSheet(
track: track,
hideQueueActions: hideQueueActions,
onBeforeNavigate: onBeforeNavigate,
onNavigate: onNavigate,
),
);
}
@@ -152,8 +154,12 @@ class TrackActionsSheet extends ConsumerWidget {
label: 'Go to album',
onTap: () {
Navigator.pop(context);
onBeforeNavigate?.call();
context.push('/albums/${track.albumId}');
final path = '/albums/${track.albumId}';
if (onNavigate != null) {
onNavigate!(path);
} else {
context.push(path);
}
},
),
_MenuItem(
@@ -162,8 +168,12 @@ class TrackActionsSheet extends ConsumerWidget {
label: 'Go to artist',
onTap: () {
Navigator.pop(context);
onBeforeNavigate?.call();
context.push('/artists/${track.artistId}');
final path = '/artists/${track.artistId}';
if (onNavigate != null) {
onNavigate!(path);
} else {
context.push(path);
}
},
),
const _Divider(),