Files
minstrel/flutter_client/lib/library/library_providers.dart
T
bvandeusen 983a9d92be fix(flutter): tighter track row + missing artist in mini player + hide redundant artist on artist page
Track row (album detail):
- Vertical padding 10→6 and only render the artist line when non-empty,
  so tracks without a populated artist row don't reserve a blank line.
- Number column 28→22 — visually tighter without making 3-digit
  numbers cramp.

Mini player missing artist:
- Symptom was an empty `artist` field on MediaItem after tapping a
  track in album detail; layout change wasn't the cause. Real cause:
  albumProvider's cold-cache only wrote cachedAlbums + cachedTracks,
  never cachedArtists. The drift JOINs that build the AlbumRef + each
  TrackRef returned null for the artist row, so artistName fell back
  to '' and the audio handler stamped MediaItem.artist=''.
- Cold-cache now collects every distinct (artistId, artistName) pair
  off the album header + each track, inserts them into cachedArtists
  alongside the album/tracks. Subsequent JOINs populate artistName,
  the audio handler stamps it on MediaItem, and the mini player picks
  it up via the existing artistName.isNotEmpty render.
- Existing cached albums won't get artist names until they're
  re-fetched (cache invalidation TBD). Future cold-cache hits will.

Artist detail album grid:
- AlbumCard gains showArtist (default true). Pass false in the artist
  detail grid since the page header already names the artist. cellH
  trimmed by the 16dp artist line.
2026-05-11 08:47:50 -04:00

253 lines
9.6 KiB
Dart

import 'package:dio/dio.dart';
import 'package:drift/drift.dart' as drift;
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/client.dart';
import '../api/endpoints/library.dart';
import '../auth/auth_provider.dart';
import '../cache/adapters.dart';
import '../cache/audio_cache_manager.dart' show appDbProvider;
import '../cache/cache_first.dart';
import '../cache/connectivity_provider.dart';
import '../cache/db.dart';
import '../models/album.dart';
import '../models/artist.dart';
import '../models/home_data.dart';
import '../models/track.dart';
/// Shared authenticated dio. This is the ONLY place tokenResolver is wired
/// to the secure-storage `session_token` read — every other endpoint
/// awaits `dioProvider.future` rather than constructing its own dio.
///
/// Riverpod will invalidate any FutureProvider that watches this when
/// the server URL changes; auth state changes don't need to invalidate
/// the provider because the resolver re-reads the token on every request.
final dioProvider = FutureProvider<Dio>((ref) async {
final url = await ref.watch(serverUrlProvider.future);
if (url == null || url.isEmpty) {
throw StateError('no server URL set');
}
final storage = ref.watch(secureStorageProvider);
return ApiClient.buildDio(
baseUrl: url,
tokenResolver: () async => storage.read(key: 'session_token'),
on401: () async => ref.read(authControllerProvider.notifier).clearSession(),
);
});
final libraryApiProvider = FutureProvider<LibraryApi>((ref) async {
return LibraryApi(await ref.watch(dioProvider.future));
});
final homeProvider = FutureProvider<HomeData>((ref) async {
return (await ref.watch(libraryApiProvider.future)).getHome();
});
/// Drift-first per #357 plan C. Watches cached_artists for the row;
/// when empty + online, fetches via REST + populates drift, which
/// re-emits via watch().
final artistProvider =
StreamProvider.family<ArtistRef, String>((ref, id) {
final db = ref.watch(appDbProvider);
return cacheFirst<CachedArtist, ArtistRef>(
driftStream: (db.select(db.cachedArtists)..where((t) => t.id.equals(id)))
.watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getArtist(id);
await db.into(db.cachedArtists).insertOnConflictUpdate(fresh.toDrift());
},
toResult: (rows) => rows.isEmpty
? const ArtistRef(id: '', name: '')
: rows.first.toRef(),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
tag: 'artist($id)',
);
});
final artistAlbumsProvider =
StreamProvider.family<List<AlbumRef>, String>((ref, artistId) {
final db = ref.watch(appDbProvider);
// Join cached_albums + cached_artists to fill artist_name on each row.
final query = db.select(db.cachedAlbums).join([
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
])..where(db.cachedAlbums.artistId.equals(artistId));
return cacheFirst<drift.TypedResult, List<AlbumRef>>(
driftStream: query.watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getArtistAlbums(artistId);
await db.batch((b) {
b.insertAllOnConflictUpdate(
db.cachedAlbums, fresh.map((a) => a.toDrift()).toList());
});
},
toResult: (rows) => rows.map((r) {
final album = r.readTable(db.cachedAlbums);
final artist = r.readTableOrNull(db.cachedArtists);
return album.toRef(artistName: artist?.name ?? '');
}).toList(),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
tag: 'artistAlbums($artistId)',
);
});
final artistTracksProvider =
StreamProvider.family<List<TrackRef>, String>((ref, artistId) {
final db = ref.watch(appDbProvider);
final query = db.select(db.cachedTracks).join([
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
drift.leftOuterJoin(db.cachedAlbums,
db.cachedAlbums.id.equalsExp(db.cachedTracks.albumId)),
])..where(db.cachedTracks.artistId.equals(artistId));
return cacheFirst<drift.TypedResult, List<TrackRef>>(
driftStream: query.watch(),
fetchAndPopulate: () async {
final api = await ref.read(libraryApiProvider.future);
final fresh = await api.getArtistTracks(artistId);
await db.batch((b) {
b.insertAllOnConflictUpdate(
db.cachedTracks, fresh.map((t) => t.toDrift()).toList());
});
},
toResult: (rows) => rows.map((r) {
final track = r.readTable(db.cachedTracks);
final artist = r.readTableOrNull(db.cachedArtists);
final album = r.readTableOrNull(db.cachedAlbums);
return track.toRef(
artistName: artist?.name ?? '',
albumTitle: album?.title ?? '',
);
}).toList(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
);
});
/// Composite shape (album + tracks) — uses async* + Stream.combineLatest
/// for reactive updates over both rows.
final albumProvider = StreamProvider.family<
({AlbumRef album, List<TrackRef> tracks}), String>((ref, albumId) async* {
final db = ref.watch(appDbProvider);
final albumQuery = (db.select(db.cachedAlbums)
..where((t) => t.id.equals(albumId)))
.join([
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedAlbums.artistId)),
]);
final tracksQuery = (db.select(db.cachedTracks)
..where((t) => t.albumId.equals(albumId))
..orderBy([
(t) => drift.OrderingTerm.asc(t.discNumber),
(t) => drift.OrderingTerm.asc(t.trackNumber),
]))
.join([
drift.leftOuterJoin(db.cachedArtists,
db.cachedArtists.id.equalsExp(db.cachedTracks.artistId)),
]);
await for (final albumRows in albumQuery.watch()) {
if (albumRows.isEmpty) {
debugPrint('albumProvider($albumId): drift miss, attempting cold-cache fetch');
// Cold cache fallback
bool online;
try {
online = await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true);
} catch (e) {
debugPrint('albumProvider($albumId): connectivity check threw $e — assuming online');
online = true;
}
debugPrint('albumProvider($albumId): online=$online');
if (online) {
try {
final api = await ref.read(libraryApiProvider.future);
debugPrint('albumProvider($albumId): calling getAlbum');
final fresh = await api
.getAlbum(albumId)
.timeout(const Duration(seconds: 10));
debugPrint('albumProvider($albumId): got ${fresh.tracks.length} tracks, writing to drift');
// Collect every artist mentioned by the album + its tracks so
// the JOINs that drive both the album header and the track
// rows have something to bind to. Without this, drift returns
// null for the artist row and artistName surfaces empty —
// visible as a missing artist line in the mini player and
// the row list.
final artists = <String, ArtistRef>{};
if (fresh.album.artistId.isNotEmpty &&
fresh.album.artistName.isNotEmpty) {
artists[fresh.album.artistId] = ArtistRef(
id: fresh.album.artistId,
name: fresh.album.artistName,
);
}
for (final t in fresh.tracks) {
if (t.artistId.isNotEmpty &&
t.artistName.isNotEmpty &&
!artists.containsKey(t.artistId)) {
artists[t.artistId] = ArtistRef(
id: t.artistId,
name: t.artistName,
);
}
}
await db.batch((b) {
if (artists.isNotEmpty) {
b.insertAllOnConflictUpdate(
db.cachedArtists, artists.values.map((a) => a.toDrift()).toList());
}
b.insertAllOnConflictUpdate(db.cachedAlbums, [fresh.album.toDrift()]);
b.insertAllOnConflictUpdate(db.cachedTracks,
fresh.tracks.map((t) => t.toDrift()).toList());
});
debugPrint('albumProvider($albumId): drift write done; awaiting watch re-emit');
// watch() re-emits with the populated rows; loop continues.
} catch (e, st) {
debugPrint('albumProvider($albumId): cold-cache fetch failed: $e\n$st');
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
}
} else {
debugPrint('albumProvider($albumId): offline, yielding empty');
yield (
album: const AlbumRef(id: '', title: '', artistId: ''),
tracks: const <TrackRef>[],
);
}
continue;
}
debugPrint('albumProvider($albumId): drift hit (${albumRows.length} rows)');
final albumRow = albumRows.first;
final album = albumRow.readTable(db.cachedAlbums).toRef(
artistName: albumRow.readTableOrNull(db.cachedArtists)?.name ?? '',
);
final trackRows = await tracksQuery.get();
final tracks = trackRows.map((r) {
final track = r.readTable(db.cachedTracks);
final artist = r.readTableOrNull(db.cachedArtists);
return track.toRef(
artistName: artist?.name ?? '',
albumTitle: album.title,
);
}).toList();
yield (album: album, tracks: tracks);
}
});