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 'package:flutter/foundation.dart' show debugPrint;
/// Wraps the watch + cold-cache fallback pattern. Generic over:
/// D — the drift row type (or TypedResult for joins)
/// 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 Future<bool> Function() isOnline,
bool alwaysRefresh = false,
String? tag,
}) async* {
// Tracks whether we've already kicked off a stale-while-revalidate
// refresh for this stream subscription, so we don't fire one on every
// drift re-emission (otherwise the populate cycles forever).
var revalidated = false;
void log(String msg) {
if (tag != null) debugPrint('cacheFirst[$tag]: $msg');
}
await for (final rows in driftStream) {
if (rows.isNotEmpty) {
log('drift hit (${rows.length} rows)');
yield toResult(rows);
// Stale-while-revalidate: yield cache immediately, then kick off
// a REST refresh in the background. Drift watch() picks up the
@@ -55,15 +62,20 @@ Stream<T> cacheFirst<D, T>({
}
continue;
}
log('drift miss; checking connectivity');
if (await isOnline()) {
log('online; calling fetchAndPopulate');
try {
await fetchAndPopulate();
log('fetchAndPopulate done; awaiting drift re-emit');
// The drift watch() stream re-emits with the populated rows on
// 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
}
} else {
log('offline; yielding empty');
yield toResult(rows); // empty result; offline
}
}
@@ -6,6 +6,7 @@ import '../cache/audio_cache_manager.dart';
import '../cache/db.dart';
import '../likes/like_button.dart';
import '../player/player_provider.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart';
import 'library_providers.dart';
import 'widgets/track_row.dart';
@@ -27,7 +28,18 @@ class AlbumDetailScreen extends ConsumerWidget {
Padding(
padding: const EdgeInsets.all(16),
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),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -6,6 +6,7 @@ import '../api/endpoints/likes.dart';
import '../likes/like_button.dart';
import '../models/album.dart';
import '../player/player_provider.dart';
import '../shared/widgets/server_image.dart';
import '../theme/theme_extension.dart';
import 'library_providers.dart';
import 'widgets/album_card.dart';
@@ -30,9 +31,18 @@ class ArtistDetailScreen extends ConsumerWidget {
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
Container(
width: 96, height: 96,
decoration: BoxDecoration(color: fs.slate, shape: BoxShape.circle),
ClipOval(
child: SizedBox(
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),
Expanded(
@@ -61,8 +61,10 @@ final artistProvider =
toResult: (rows) => rows.isEmpty
? const ArtistRef(id: '', name: '')
: rows.first.toRef(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
isOnline: () async => (await ref
.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);
return album.toRef(artistName: artist?.name ?? '');
}).toList(),
isOnline: () async =>
(await ref.read(connectivityProvider.future)),
isOnline: () async => (await ref
.read(connectivityProvider.future)
.timeout(const Duration(seconds: 3), onTimeout: () => true)),
tag: 'artistAlbums($artistId)',
);
});