Files
bvandeusen a5e2abb8c4 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>
2026-05-15 21:37:29 -04:00

122 lines
5.4 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:dio/dio.dart';
import '../../models/album.dart';
import '../../models/artist.dart';
import '../../models/home_data.dart';
import '../../models/home_index.dart';
import '../../models/track.dart';
/// LibraryApi wraps the server's native /api/* library surface.
///
/// Response shapes (verified against internal/api/library.go,
/// internal/api/library_albums.go, internal/api/home.go):
///
/// GET /api/home → HomePayload (flat sections)
/// GET /api/artists/{id} → ArtistDetail (ArtistRef fields + "albums")
/// GET /api/artists/{id}/tracks → flat []TrackRef array (NOT enveloped)
/// GET /api/albums/{id} → AlbumDetail (AlbumRef fields + "tracks")
///
/// The artist-tracks endpoint deviates from the plan-text starter: the
/// server emits a bare JSON array, not `{"tracks": [...]}`. We parse the
/// top-level response as `List` accordingly.
class LibraryApi {
LibraryApi(this._dio);
final Dio _dio;
Future<HomeData> getHome() async {
final r = await _dio.get<Map<String, dynamic>>('/api/home');
return HomeData.fromJson(r.data ?? const {});
}
/// GET /api/home/index — per-item rendering variant. Returns just IDs
/// per section; client hydrates each tile via the per-entity
/// endpoints. ~10× smaller than /api/home on populated libraries so
/// the cold-visit round-trip is correspondingly short.
Future<HomeIndex> getHomeIndex() async {
final r = await _dio.get<Map<String, dynamic>>('/api/home/index');
return HomeIndex.fromJson(r.data ?? const {});
}
/// GET /api/tracks/{id}. Returns the canonical TrackRef. Used by
/// the HydrationQueue to populate cached_tracks on a per-tile miss
/// — the existing endpoint already joins album + artist so the
/// response carries everything TrackRef needs.
Future<TrackRef> getTrack(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/tracks/$id');
return TrackRef.fromJson(r.data ?? const {});
}
/// GET /api/artists/{id}. Server returns ArtistDetail which embeds
/// ArtistRef inline; ArtistRef.fromJson already reads only the fields
/// it cares about, so passing the whole body is correct.
Future<ArtistRef> getArtist(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
return ArtistRef.fromJson(r.data ?? const {});
}
/// GET /api/artists/{id} — full response with the ArtistRef AND the
/// embedded album list, both parsed. Single round-trip variant used
/// by CacheFiller and other callers that want to populate both
/// cached_artists and cached_albums in one shot. The existing
/// getArtist / getArtistAlbums keep working for callers that only
/// need one half — they hit the same URL but the per-id Riverpod
/// caching layer dedupes.
Future<({ArtistRef artist, List<AlbumRef> albums})> getArtistDetail(
String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
final body = r.data ?? const <String, dynamic>{};
final artist = ArtistRef.fromJson(body);
final albums = ((body['albums'] as List?) ?? const [])
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
return (artist: artist, albums: albums);
}
/// Pulls the "albums" array out of the same ArtistDetail body. Callers
/// that need both the artist and its albums should issue two provider
/// reads (artistProvider + artistAlbumsProvider) — both hit the same
/// underlying URL and dio's response is not memoized here, but the
/// Riverpod layer caches per-id so cost stays at one round-trip.
Future<List<AlbumRef>> getArtistAlbums(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/artists/$id');
final raw = (r.data?['albums'] as List?) ?? const [];
return raw
.map((e) => AlbumRef.fromJson((e as Map).cast<String, dynamic>()))
.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 {
final r = await _dio.get<List<dynamic>>('/api/artists/$id/tracks');
final raw = r.data ?? const [];
return raw
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
}
/// GET /api/albums/{id}. Returns the album ref alongside its track list
/// in a single record so screens can render both without a second fetch.
Future<({AlbumRef album, List<TrackRef> tracks})> getAlbum(String id) async {
final r = await _dio.get<Map<String, dynamic>>('/api/albums/$id');
final body = r.data ?? const <String, dynamic>{};
final tracks = ((body['tracks'] as List?) ?? const [])
.map((e) => TrackRef.fromJson((e as Map).cast<String, dynamic>()))
.toList(growable: false);
return (album: AlbumRef.fromJson(body), tracks: tracks);
}
}