fix(artwork): stop memoizing posters that merely have no client yet
Distinguish an unresolvable URL from a failed load at the error-widget boundary. A transiently null media client during a profile switch or reconnect marked the primary poster dead in a process-global set, pinning the item to fallback artwork for the rest of the session.
This commit is contained in:
@@ -944,8 +944,12 @@ Widget _buildPosterImage(
|
||||
// Remember the dead primary URL so later builds go straight to the fallback.
|
||||
Widget Function(BuildContext, String, dynamic)? retryWithFallback(ImageType type) {
|
||||
if (posterFallbackUrl == null || useRememberedFallback) return null;
|
||||
return (_, _, _) {
|
||||
_rememberFailedPosterUrl(primaryPosterUrl);
|
||||
return (_, _, error) {
|
||||
// Only an attempted-and-failed load proves the primary artwork is dead.
|
||||
// An unresolvable URL just means there is no client right now (offline,
|
||||
// profile switch, reconnect); memoizing it would pin this item to
|
||||
// fallback artwork for the process lifetime.
|
||||
if (error is! UnresolvedImageUrl) _rememberFailedPosterUrl(primaryPosterUrl);
|
||||
return buildImage(posterFallbackUrl, type);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,20 @@ int _imageFailureCount = 0;
|
||||
DateTime _lastFailureLog = DateTime.now();
|
||||
const _logInterval = Duration(seconds: 10);
|
||||
|
||||
/// Passed to [OptimizedMediaImage.errorWidget] when no URL could be built for
|
||||
/// the image *at this build*, as opposed to a load that was attempted and
|
||||
/// failed. Reaching [OptimizedMediaImage._buildCachedImage] already implies a
|
||||
/// non-empty [OptimizedMediaImage.imagePath], and the only remaining way
|
||||
/// [MediaImageHelper.getOptimizedImageUrl] returns '' for one is a null
|
||||
/// [MediaServerClient] (offline mode, profile switch, server reconnect), so the
|
||||
/// path is not known-bad and callers that memoize failures MUST NOT record it.
|
||||
class UnresolvedImageUrl implements Exception {
|
||||
const UnresolvedImageUrl(this.imagePath);
|
||||
final String imagePath;
|
||||
@override
|
||||
String toString() => 'No image URL could be built for $imagePath';
|
||||
}
|
||||
|
||||
Widget blurArtwork(Widget child, {double sigma = 30, bool clip = true}) {
|
||||
if (!kBlurArtwork) return child;
|
||||
final filtered = ImageFiltered(
|
||||
@@ -239,11 +253,7 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
|
||||
if (!hasLocal && (imagePath == null || imagePath!.isEmpty)) {
|
||||
if (errorWidget != null) {
|
||||
return errorWidget!(
|
||||
context,
|
||||
localFilePath ?? '',
|
||||
FileSystemException('Local image file is unavailable', localFilePath),
|
||||
);
|
||||
return errorWidget!(context, localFilePath ?? '', UnresolvedImageUrl(localFilePath ?? imagePath ?? ''));
|
||||
}
|
||||
return _buildFallback(context);
|
||||
}
|
||||
@@ -335,7 +345,7 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
// load failure from the caller's point of view, so honour its own
|
||||
// failure UI rather than the generic broken-image tile.
|
||||
if (errorWidget != null) {
|
||||
return errorWidget!(context, imagePath ?? '', StateError('No image URL could be built for $imagePath'));
|
||||
return errorWidget!(context, imagePath ?? '', UnresolvedImageUrl(imagePath ?? ''));
|
||||
}
|
||||
return _buildFallback(context);
|
||||
}
|
||||
|
||||
@@ -209,6 +209,52 @@ void main() {
|
||||
expect((fallback as OptimizedMediaImage).imagePath, albumArtwork);
|
||||
expect(fallback.imageType, ImageType.square);
|
||||
});
|
||||
|
||||
testWidgets('unresolved track artwork fallback is not memoized', (tester) async {
|
||||
const trackArtwork = 'https://poster-memo.example/unresolved-track.jpg';
|
||||
const albumArtwork = 'https://poster-memo.example/unresolved-album.jpg';
|
||||
final item = testMediaItem(
|
||||
id: 'track-with-unresolved-artwork',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.track,
|
||||
title: 'Track',
|
||||
thumbPath: trackArtwork,
|
||||
parentThumbPath: albumArtwork,
|
||||
);
|
||||
|
||||
Future<void> pumpCard() => tester.pumpWidget(
|
||||
_TestApp(child: MediaCard(item: item, width: 200, height: 194, forceGridMode: true, isOffline: true)),
|
||||
);
|
||||
|
||||
await pumpCard();
|
||||
final primaryFinder = find.descendant(of: find.byType(MediaCard), matching: find.byType(OptimizedMediaImage)).first;
|
||||
final primary = tester.widget<OptimizedMediaImage>(primaryFinder);
|
||||
expect(primary.imagePath, trackArtwork);
|
||||
expect(primary.errorWidget, isNotNull);
|
||||
|
||||
final unresolvedFallback = primary.errorWidget!(
|
||||
tester.element(primaryFinder),
|
||||
trackArtwork,
|
||||
const UnresolvedImageUrl(trackArtwork),
|
||||
);
|
||||
expect(unresolvedFallback, isA<OptimizedMediaImage>());
|
||||
expect((unresolvedFallback as OptimizedMediaImage).imagePath, albumArtwork);
|
||||
|
||||
await pumpCard();
|
||||
final unresolvedRebuild = tester.widget<OptimizedMediaImage>(primaryFinder);
|
||||
expect(unresolvedRebuild.imagePath, trackArtwork);
|
||||
|
||||
final failedFallback = unresolvedRebuild.errorWidget!(
|
||||
tester.element(primaryFinder),
|
||||
trackArtwork,
|
||||
StateError('decode failed'),
|
||||
);
|
||||
expect(failedFallback, isA<OptimizedMediaImage>());
|
||||
expect((failedFallback as OptimizedMediaImage).imagePath, albumArtwork);
|
||||
|
||||
await pumpCard();
|
||||
expect(tester.widget<OptimizedMediaImage>(primaryFinder).imagePath, albumArtwork);
|
||||
});
|
||||
}
|
||||
|
||||
class _TestApp extends StatelessWidget {
|
||||
|
||||
Reference in New Issue
Block a user