From c64e6fc51986d88548aeb9153eaa7a62f6ea32f5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:44:52 +0200 Subject: [PATCH] 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. --- lib/widgets/media_card.dart | 8 +++-- lib/widgets/optimized_media_image.dart | 22 ++++++++---- test/widgets/media_card_square_test.dart | 46 ++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index f9dfcdcf..d6d870f1 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -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); }; } diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index a6aa78b9..fca86d59 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -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); } diff --git a/test/widgets/media_card_square_test.dart b/test/widgets/media_card_square_test.dart index 32b8d951..ffd00635 100644 --- a/test/widgets/media_card_square_test.dart +++ b/test/widgets/media_card_square_test.dart @@ -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 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(primaryFinder); + expect(primary.imagePath, trackArtwork); + expect(primary.errorWidget, isNotNull); + + final unresolvedFallback = primary.errorWidget!( + tester.element(primaryFinder), + trackArtwork, + const UnresolvedImageUrl(trackArtwork), + ); + expect(unresolvedFallback, isA()); + expect((unresolvedFallback as OptimizedMediaImage).imagePath, albumArtwork); + + await pumpCard(); + final unresolvedRebuild = tester.widget(primaryFinder); + expect(unresolvedRebuild.imagePath, trackArtwork); + + final failedFallback = unresolvedRebuild.errorWidget!( + tester.element(primaryFinder), + trackArtwork, + StateError('decode failed'), + ); + expect(failedFallback, isA()); + expect((failedFallback as OptimizedMediaImage).imagePath, albumArtwork); + + await pumpCard(); + expect(tester.widget(primaryFinder).imagePath, albumArtwork); + }); } class _TestApp extends StatelessWidget {