feat(flutter): replace Download with Regenerate on system playlist detail

Per operator decision: in the playlist detail header, system
playlists (For You / Discover) now show a Regenerate button where
user playlists keep Download. Offline-download is intentionally
dropped for system playlists — operator chose the literal swap.

- Regenerate calls PlaylistsApi.refreshSystem(variant), invalidates
  playlistsListProvider (home row tile rebinds to the rotated UUID),
  and pushReplacement's to /playlists/<newId> so the open detail
  screen rebinds instead of 404-ing on the stale id.
- Null id (empty library) and errors surface as snackbars.
- User playlists are unchanged (Download + Play).

The home-card kebab (#416, 7a04370) stays — web has refresh in both
the detail view and the home tile, so this matches web parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 06:55:37 -04:00
parent 7a0437087a
commit 45c72993f3
@@ -263,24 +263,32 @@ class _Header extends ConsumerWidget {
), ),
const Spacer(), const Spacer(),
if (playable.isNotEmpty) ...[ if (playable.isNotEmpty) ...[
OutlinedButton.icon( if (p.isSystem)
key: const Key('download_playlist_button'), OutlinedButton.icon(
onPressed: () { key: const Key('regenerate_playlist_button'),
final mgr = ref.read(audioCacheManagerProvider); onPressed: () => _regenerate(context, ref),
for (final t in playable) { icon: const Icon(Icons.refresh, size: 16),
final id = t.trackId ?? ''; label: const Text('Regenerate'),
if (id.isEmpty) continue; )
// Fire-and-forget; downloads happen in background. else
// ignore: unawaited_futures OutlinedButton.icon(
mgr.pin(id, source: CacheSource.autoPlaylist); key: const Key('download_playlist_button'),
} onPressed: () {
ScaffoldMessenger.of(context).showSnackBar( final mgr = ref.read(audioCacheManagerProvider);
SnackBar(content: Text('Downloading ${playable.length} tracks…')), for (final t in playable) {
); final id = t.trackId ?? '';
}, if (id.isEmpty) continue;
icon: const Icon(Icons.download, size: 16), // Fire-and-forget; downloads happen in background.
label: const Text('Download'), // ignore: unawaited_futures
), mgr.pin(id, source: CacheSource.autoPlaylist);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Downloading ${playable.length} tracks…')),
);
},
icon: const Icon(Icons.download, size: 16),
label: const Text('Download'),
),
const SizedBox(width: 8), const SizedBox(width: 8),
FilledButton.icon( FilledButton.icon(
onPressed: () { onPressed: () {
@@ -299,6 +307,37 @@ class _Header extends ConsumerWidget {
]), ]),
); );
} }
/// Forces a server-side rebuild of this system playlist. The
/// rebuild rotates the playlist UUID, so the old detail route now
/// 404s — rebind by pushReplacement-ing to the new id. The
/// aggregate list is invalidated too so the home row's tile points
/// at the fresh UUID. ScaffoldMessenger + router captured before
/// the await so we don't touch a stale BuildContext after.
Future<void> _regenerate(BuildContext context, WidgetRef ref) async {
final messenger = ScaffoldMessenger.of(context);
final router = GoRouter.of(context);
final p = detail.playlist;
try {
final api = await ref.read(playlistsApiProvider.future);
final newId = await api.refreshSystem(p.systemVariant!);
ref.invalidate(playlistsListProvider);
if (newId == null) {
messenger.showSnackBar(const SnackBar(
content: Text('Nothing to build yet — library is empty.'),
));
return;
}
messenger.showSnackBar(
SnackBar(content: Text('${p.name} regenerated')),
);
router.pushReplacement('/playlists/$newId');
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text("Couldn't regenerate: $e")),
);
}
}
} }
class _PlaylistTrackRow extends ConsumerWidget { class _PlaylistTrackRow extends ConsumerWidget {