Files
minstrel/flutter_client/lib/api/endpoints/library.dart
T
bvandeusen 03c13d21c6 feat(flutter): home screen on per-item rendering (Slice C)
End-to-end pilot of the per-item architecture. Home now reads from
the new homeIndexProvider (drift-first over CachedHomeIndex with
/api/home/index discovery + SWR), then each tile is a small
ConsumerWidget watching its own albumTileProvider/artistTileProvider/
trackTileProvider. Tiles render a matched-dimension skeleton while
their entity is still hydrating, and swap in the real card once
drift emits the populated row.

Track hydration is wired up — /api/tracks/:id already existed so
the queue's case 'track' just calls api.getTrack(id) and persists.

The visible behavior:
* Cold visit: small /api/home/index round-trip (IDs only, ~10×
  smaller than /api/home), then sections appear shaped with
  skeleton tiles; each tile materializes as the hydration queue
  drains. No more "30s blank → everything pops in at once."
* Warm visit: drift index emits instantly, drift entity rows emit
  instantly, no network. Page paints fully in the first frame.
* Mid-state: scrolling through a partially-hydrated section sees
  real cards next to skeleton cards. Layout doesn't shift because
  skeletons match real-card dimensions exactly.

CachedHomeSnapshot (and the legacy homeProvider) stay in place but
unconsumed by Flutter — left in for now so revert is cheap if the
new path needs reworking. Cleanup follow-up in a later slice.

Old /api/home endpoint untouched, so the web client keeps working
unchanged.
2026-05-13 21:05:43 -04:00

91 lines
4.0 KiB
Dart
Raw 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 {});
}
/// 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/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);
}
}