feat(offline): #427 S4a — Shuffle all + offline system-playlist gate

Headline of S4. "Shuffle all" is always present (home app-bar
shuffle icon); the pool degrades with reachability:
- online  → GET /api/library/shuffle?limit=N (new): N random
  library tracks server-side, per-user quarantine filtered
  (ListRandomTracksForUser, ORDER BY random()).
- offline → ShuffleSource shuffles the whole local cache index
  (audio_cache_index ∩ cached_tracks, names from cached_artists/
  albums) — a UNION over liked AND recently-played, since the
  two-bucket split is storage-only and never filters playback.

Offline gating: refreshable (singleton) system playlists need the
live build/shuffle endpoints, so their tile play is disabled when
offlineProvider is true — Shuffle all is the offline path. User
playlists still play from cache.

playlist_card now reads offlineProvider in build → wrapped the
direct-render widget test in ProviderScope (offlineProvider is
smoke-safe from S1: tracked timers, no connectivity mount).

S4b (offline Recently-played / Liked browsable surfaces over the
cache index) next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 21:37:29 -04:00
parent a4f293b7cf
commit a5e2abb8c4
9 changed files with 248 additions and 10 deletions
@@ -85,6 +85,19 @@ class LibraryApi {
.toList(growable: false);
}
/// GET /api/library/shuffle?limit=N (#427 S4). N random library
/// tracks — the online source for "Shuffle all". Bare JSON array.
Future<List<TrackRef>> shuffle({int limit = 100}) async {
final r = await _dio.get<List<dynamic>>(
'/api/library/shuffle',
queryParameters: {'limit': limit},
);
final raw = r.data ?? const [];
return raw
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// GET /api/artists/{id}/tracks. Server emits a bare JSON array, so we
/// type the response as `List<dynamic>` rather than a Map envelope.
Future<List<TrackRef>> getArtistTracks(String id) async {