fix(playlists): surface playlist-load failures with SnackBar feedback (#479)

Tapping a system-playlist PlayCircleButton before the mix had loaded
silently stalled — three failure modes all looked identical to the
user (PlayCircleButton's spinner clears with no playback):

  - api.systemShuffle returns empty (mix not built server-side yet)
    → silent return at `if (refs.isEmpty)`
  - api call slow / hung → spinner spins indefinitely (no client
    timeout was set; Dio default is unlimited)
  - api throws → uncaught; spinner finalizer clears it silently

Bundle:

  - 8s `.timeout` on the systemShuffle / get call; TimeoutException
    → "Couldn't load playlist — check your connection"
  - empty refs after filtering → "Mix isn't ready yet — try again
    in a moment"
  - other throws → "Playlist load failed: <error>"
  - thread BuildContext through and capture ScaffoldMessenger before
    the first await so no `use_build_context_synchronously` lint

No pre-warm — system playlists are intentionally uncached per the
api endpoint comment ("varies per play"); pre-warming would decide
the shuffle order at home-screen load instead of at tap, breaking
the fresh-per-play contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 09:16:00 -04:00
parent b93b116e4f
commit 6df02428b0
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -70,7 +72,7 @@ class PlaylistCard extends ConsumerWidget {
right: 6, right: 6,
child: PlayCircleButton( child: PlayCircleButton(
enabled: hasTracks, enabled: hasTracks,
onPressed: () => _playPlaylist(ref), onPressed: () => _playPlaylist(context, ref),
), ),
), ),
// System playlists get a refresh affordance so the // System playlists get a refresh affordance so the
@@ -187,16 +189,34 @@ class PlaylistCard extends ConsumerWidget {
/// PlaylistTrack into a TrackRef (filtering out unavailable rows /// PlaylistTrack into a TrackRef (filtering out unavailable rows
/// whose `trackId` is null after a track-delete), and plays from /// whose `trackId` is null after a track-delete), and plays from
/// index 0. Mirrors the web PlaylistCard's onPlayClick. /// index 0. Mirrors the web PlaylistCard's onPlayClick.
Future<void> _playPlaylist(WidgetRef ref) async { ///
/// Failure modes are surfaced via SnackBar so a not-yet-built mix
/// or a slow server doesn't look like the tap was ignored.
Future<void> _playPlaylist(BuildContext context, WidgetRef ref) async {
final messenger = ScaffoldMessenger.of(context);
final api = await ref.read(playlistsApiProvider.future); final api = await ref.read(playlistsApiProvider.future);
// Refreshable (singleton) system playlists: fetch the server's final PlaylistDetail detail;
// rotation-aware order (#415) and play as-is, tagged with the try {
// source so the reporter advances rotation. User playlists AND // Refreshable (singleton) system playlists: fetch the server's
// non-singleton system kinds (songs_like_artist — no by-kind // rotation-aware order (#415) and play as-is, tagged with the
// endpoint) play the stored order via get(), untagged. // source so the reporter advances rotation. User playlists AND
final detail = playlist.refreshable // non-singleton system kinds (songs_like_artist — no by-kind
? await api.systemShuffle(playlist.systemVariant!) // endpoint) play the stored order via get(), untagged.
: await api.get(playlist.id); detail = await (playlist.refreshable
? api.systemShuffle(playlist.systemVariant!)
: api.get(playlist.id))
.timeout(const Duration(seconds: 8));
} on TimeoutException {
messenger.showSnackBar(const SnackBar(
content: Text("Couldn't load playlist — check your connection"),
));
return;
} catch (e) {
messenger.showSnackBar(SnackBar(
content: Text('Playlist load failed: $e'),
));
return;
}
final refs = <TrackRef>[]; final refs = <TrackRef>[];
for (final t in detail.tracks) { for (final t in detail.tracks) {
if (t.trackId == null) continue; if (t.trackId == null) continue;
@@ -211,7 +231,12 @@ class PlaylistCard extends ConsumerWidget {
streamUrl: t.streamUrl ?? '', streamUrl: t.streamUrl ?? '',
)); ));
} }
if (refs.isEmpty) return; if (refs.isEmpty) {
messenger.showSnackBar(const SnackBar(
content: Text("Mix isn't ready yet — try again in a moment"),
));
return;
}
// Rotation-aware kinds arrive pre-ordered from the server — play // Rotation-aware kinds arrive pre-ordered from the server — play
// as-is, tagged so the reporter advances rotation. No client // as-is, tagged so the reporter advances rotation. No client
// shuffle. Everything else plays stored order, untagged. // shuffle. Everything else plays stored order, untagged.