From 6df02428b0c7281b85cc0747e75c7da25ec0654c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 21 May 2026 09:16:00 -0400 Subject: [PATCH] fix(playlists): surface playlist-load failures with SnackBar feedback (#479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: " - 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) --- .../lib/playlists/widgets/playlist_card.dart | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/flutter_client/lib/playlists/widgets/playlist_card.dart b/flutter_client/lib/playlists/widgets/playlist_card.dart index 7e16cc8f..7c948b41 100644 --- a/flutter_client/lib/playlists/widgets/playlist_card.dart +++ b/flutter_client/lib/playlists/widgets/playlist_card.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -70,7 +72,7 @@ class PlaylistCard extends ConsumerWidget { right: 6, child: PlayCircleButton( enabled: hasTracks, - onPressed: () => _playPlaylist(ref), + onPressed: () => _playPlaylist(context, ref), ), ), // System playlists get a refresh affordance so the @@ -187,16 +189,34 @@ class PlaylistCard extends ConsumerWidget { /// PlaylistTrack into a TrackRef (filtering out unavailable rows /// whose `trackId` is null after a track-delete), and plays from /// index 0. Mirrors the web PlaylistCard's onPlayClick. - Future _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 _playPlaylist(BuildContext context, WidgetRef ref) async { + final messenger = ScaffoldMessenger.of(context); final api = await ref.read(playlistsApiProvider.future); - // Refreshable (singleton) system playlists: fetch the server's - // rotation-aware order (#415) and play as-is, tagged with the - // source so the reporter advances rotation. User playlists AND - // non-singleton system kinds (songs_like_artist — no by-kind - // endpoint) play the stored order via get(), untagged. - final detail = playlist.refreshable - ? await api.systemShuffle(playlist.systemVariant!) - : await api.get(playlist.id); + final PlaylistDetail detail; + try { + // Refreshable (singleton) system playlists: fetch the server's + // rotation-aware order (#415) and play as-is, tagged with the + // source so the reporter advances rotation. User playlists AND + // non-singleton system kinds (songs_like_artist — no by-kind + // endpoint) play the stored order via get(), untagged. + 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 = []; for (final t in detail.tracks) { if (t.trackId == null) continue; @@ -211,7 +231,12 @@ class PlaylistCard extends ConsumerWidget { 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 // as-is, tagged so the reporter advances rotation. No client // shuffle. Everything else plays stored order, untagged.