Files
minstrel/flutter_client/lib/library/library_providers.dart
T
bvandeusen 3c0806d8fd diag(flutter): time-box connectivity check + trace album cold-cache path
Two changes to surface where detail-screen spinners hang:

1. Connectivity().checkConnectivity() goes over a platform channel
   that on some Android builds stalls. Wrap with a 2s timeout and
   default to "online" if it times out — being wrong about
   connectivity costs at most one failed fetch, but being stuck
   spins the UI forever.

2. albumProvider's cold-cache flow now debugPrints at every step:
   drift miss → connectivity result → API call → drift write → ack
   the re-emit. Also adds a 10s timeout on the API fetch and a 3s
   timeout on the connectivity await so a hang surfaces as an
   error-yielded empty AlbumRef instead of an infinite spinner.

Once the operator's next test produces logs we can pinpoint which
step is hanging. The diagnostic prints stay until the issue's root
cause is confirmed; will be removed in a follow-up.
2026-05-11 08:28:32 -04:00

221 lines
8.2 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)),
);
});
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)),
);
});
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');
await db.batch((b) {
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);
}
});