fix(flutter): album/artist detail covers + cacheFirst tracing for hangs

Album detail screen rendered the cover slot as a bare slate Container
— no actual image. Wire ServerImage at /api/albums/<id>/cover (96dp,
rounded). Same for artist detail: ClipOval around ServerImage of the
artist's coverUrl, falling back to slate when empty.

Artist navigation hang: artistProvider goes through cacheFirst, which
had no logging — so we couldn't see whether the cold-cache fetch was
running. Add an optional `tag` parameter that, when set, debugPrints
each step (drift hit/miss, connectivity, fetch start/done/error).
Wire tags into artistProvider and artistAlbumsProvider, plus add the
3s connectivity timeout there too as belt-and-suspenders.

Next test pass should show cacheFirst[artist(<id>)]: lines that
pinpoint where the artist screen is sticking.
This commit is contained in:
2026-05-11 08:35:02 -04:00
parent 3c0806d8fd
commit f88e82a777
4 changed files with 47 additions and 9 deletions
+13 -1
View File
@@ -17,6 +17,8 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
/// Wraps the watch + cold-cache fallback pattern. Generic over: /// Wraps the watch + cold-cache fallback pattern. Generic over:
/// D — the drift row type (or TypedResult for joins) /// D — the drift row type (or TypedResult for joins)
/// T — the result type the caller wants (e.g. `List<ArtistRef>`) /// T — the result type the caller wants (e.g. `List<ArtistRef>`)
@@ -35,14 +37,19 @@ Stream<T> cacheFirst<D, T>({
required T Function(List<D>) toResult, required T Function(List<D>) toResult,
required Future<bool> Function() isOnline, required Future<bool> Function() isOnline,
bool alwaysRefresh = false, bool alwaysRefresh = false,
String? tag,
}) async* { }) async* {
// Tracks whether we've already kicked off a stale-while-revalidate // Tracks whether we've already kicked off a stale-while-revalidate
// refresh for this stream subscription, so we don't fire one on every // refresh for this stream subscription, so we don't fire one on every
// drift re-emission (otherwise the populate cycles forever). // drift re-emission (otherwise the populate cycles forever).
var revalidated = false; var revalidated = false;
void log(String msg) {
if (tag != null) debugPrint('cacheFirst[$tag]: $msg');
}
await for (final rows in driftStream) { await for (final rows in driftStream) {
if (rows.isNotEmpty) { if (rows.isNotEmpty) {
log('drift hit (${rows.length} rows)');
yield toResult(rows); yield toResult(rows);
// Stale-while-revalidate: yield cache immediately, then kick off // Stale-while-revalidate: yield cache immediately, then kick off
// a REST refresh in the background. Drift watch() picks up the // a REST refresh in the background. Drift watch() picks up the
@@ -55,15 +62,20 @@ Stream<T> cacheFirst<D, T>({
} }
continue; continue;
} }
log('drift miss; checking connectivity');
if (await isOnline()) { if (await isOnline()) {
log('online; calling fetchAndPopulate');
try { try {
await fetchAndPopulate(); await fetchAndPopulate();
log('fetchAndPopulate done; awaiting drift re-emit');
// The drift watch() stream re-emits with the populated rows on // The drift watch() stream re-emits with the populated rows on
// the next loop iteration. Don't yield here. // the next loop iteration. Don't yield here.
} catch (_) { } catch (e, st) {
log('fetchAndPopulate failed: $e\n$st');
yield toResult(rows); // empty result; caller surfaces error yield toResult(rows); // empty result; caller surfaces error
} }
} else { } else {
log('offline; yielding empty');
yield toResult(rows); // empty result; offline yield toResult(rows); // empty result; offline
} }
} }
@@ -6,6 +6,7 @@ import '../cache/audio_cache_manager.dart';
import '../cache/db.dart'; import '../cache/db.dart';
import '../likes/like_button.dart'; import '../likes/like_button.dart';
import '../player/player_provider.dart'; import '../player/player_provider.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart'; import '../theme/theme_extension.dart';
import 'library_providers.dart'; import 'library_providers.dart';
import 'widgets/track_row.dart'; import 'widgets/track_row.dart';
@@ -27,7 +28,18 @@ class AlbumDetailScreen extends ConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row(children: [ child: Row(children: [
Container(width: 96, height: 96, color: fs.slate), ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: 96,
height: 96,
child: ServerImage(
url: '/api/albums/$id/cover',
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
),
),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded(child: Column( Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -6,6 +6,7 @@ import '../api/endpoints/likes.dart';
import '../likes/like_button.dart'; import '../likes/like_button.dart';
import '../models/album.dart'; import '../models/album.dart';
import '../player/player_provider.dart'; import '../player/player_provider.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart'; import '../theme/theme_extension.dart';
import 'library_providers.dart'; import 'library_providers.dart';
import 'widgets/album_card.dart'; import 'widgets/album_card.dart';
@@ -30,9 +31,18 @@ class ArtistDetailScreen extends ConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row(children: [ child: Row(children: [
Container( ClipOval(
width: 96, height: 96, child: SizedBox(
decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle), width: 96,
height: 96,
child: a.coverUrl.isEmpty
? Container(color: fs.slate)
: ServerImage(
url: a.coverUrl,
fit: BoxFit.cover,
fallback: Container(color: fs.slate),
),
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
@@ -61,8 +61,10 @@ final artistProvider =
toResult: (rows) => rows.isEmpty toResult: (rows) => rows.isEmpty
? const ArtistRef(id: '', name: '') ? const ArtistRef(id: '', name: '')
: rows.first.toRef(), : rows.first.toRef(),
isOnline: () async => isOnline: () async => (await ref
(await ref.read(connectivityProvider.future)), .read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
tag: 'artist($id)',
); );
}); });
@@ -90,8 +92,10 @@ final artistAlbumsProvider =
final artist = r.readTableOrNull(db.cachedArtists); final artist = r.readTableOrNull(db.cachedArtists);
return album.toRef(artistName: artist?.name ?? ''); return album.toRef(artistName: artist?.name ?? '');
}).toList(), }).toList(),
isOnline: () async => isOnline: () async => (await ref
(await ref.read(connectivityProvider.future)), .read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
tag: 'artistAlbums($artistId)',
); );
}); });