From a3985f1138565464e3f03b11c4ed96da199de884 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 11 May 2026 07:44:28 -0400 Subject: [PATCH] =?UTF-8?q?fix(flutter):=20cover=20images=20=E2=80=94=20wa?= =?UTF-8?q?it=20for=20token,=20file://=20loader,=20graceful=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 401s on /api/albums//cover were the root cause of "only the first tile is navigable" — failed images leaving slate placeholders that combined with the deferToChild hit-test (fixed in d703fc2) to silently swallow taps. ServerImage: - sessionTokenProvider is a FutureProvider; .value is null after a hot restart until secure-storage resolves. Old code fired Image.network with headers:null at that moment, the network image cache locked in the 401 response, and never retried. Switch to AsyncValue.when so the widget waits for the token before mounting Image.network with the auth header attached. - Add errorBuilder so a single failed image renders the parent fallback instead of leaking a stack trace + broken-icon glyph. Player bar: - media.artUri is set by AlbumCoverCache as Uri.file(path). Wrapping it in NetworkImage attempts HTTP on a file:// URI and fails. Use FileImage when the scheme is file, else NetworkImage. Net effect: failed image loads no longer leak errors or block other widgets from rendering / receiving taps. --- flutter_client/lib/player/player_bar.dart | 9 +++++- .../lib/shared/widgets/server_image.dart | 30 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/flutter_client/lib/player/player_bar.dart b/flutter_client/lib/player/player_bar.dart index 6785d296..674cb6fb 100644 --- a/flutter_client/lib/player/player_bar.dart +++ b/flutter_client/lib/player/player_bar.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -85,7 +87,12 @@ class _TrackInfoColumn extends StatelessWidget { children: [ if (media.artUri != null) Image( - image: NetworkImage(media.artUri.toString()), + // artUri is a file:// path written by AlbumCoverCache — + // FileImage is the right loader; NetworkImage would + // attempt HTTP on a file:// URI and fail. + image: media.artUri!.isScheme('file') + ? FileImage(File.fromUri(media.artUri!)) as ImageProvider + : NetworkImage(media.artUri.toString()), width: 48, height: 48, errorBuilder: (_, __, ___) => diff --git a/flutter_client/lib/shared/widgets/server_image.dart b/flutter_client/lib/shared/widgets/server_image.dart index 2b8ca417..be44538e 100644 --- a/flutter_client/lib/shared/widgets/server_image.dart +++ b/flutter_client/lib/shared/widgets/server_image.dart @@ -35,11 +35,31 @@ class ServerImage extends ConsumerWidget { // doesn't carry cookies/Bearer tokens automatically the way the // browser's tag does, so we forward the session token as a // header. Without this, the server returns 401 and the image fails. - final token = ref.watch(sessionTokenProvider).value; - final headers = (token != null && token.isNotEmpty) - ? {'Authorization': 'Bearer $token'} - : null; - return Image.network(resolved, fit: fit, headers: headers); + // + // sessionTokenProvider is a FutureProvider — on first read after a + // hot restart its .value is null until the secure-storage read + // resolves. If we fired Image.network with headers:null at that + // moment, the network image cache would lock in the 401 response + // and never retry. Instead, hold the fallback until the token is + // available, then mount the Image with the auth header attached. + final tokenAsync = ref.watch(sessionTokenProvider); + return tokenAsync.when( + loading: () => empty, + error: (_, __) => empty, + data: (token) { + final headers = (token != null && token.isNotEmpty) + ? {'Authorization': 'Bearer $token'} + : null; + return Image.network( + resolved, + fit: fit, + headers: headers, + // Keep failures local — a single 401/timeout shouldn't dump + // a stack trace or replace the parent Container's background. + errorBuilder: (_, __, ___) => empty, + ); + }, + ); } static String? _resolve(String? baseUrl, String url) {