983a9d92be
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.
253 lines
9.6 KiB
Dart
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);
|
|
}
|
|
});
|