a3985f1138
The 401s on /api/albums/<id>/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.
76 lines
2.8 KiB
Dart
76 lines
2.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../auth/auth_provider.dart';
|
|
|
|
/// Image.network wrapper that resolves server-relative URLs (e.g.
|
|
/// `/api/albums/<id>/cover`) against the configured server base URL
|
|
/// from [serverUrlProvider]. Absolute URLs (with scheme) pass through
|
|
/// unchanged. Empty/null base or empty URL render the [fallback]
|
|
/// (defaults to a transparent SizedBox so callers can supply their own
|
|
/// surrounding placeholder).
|
|
///
|
|
/// Mirrors the absolute-or-relative logic the audio handler uses for
|
|
/// stream URLs, so cover art and audio resolve URLs the same way.
|
|
class ServerImage extends ConsumerWidget {
|
|
const ServerImage({
|
|
super.key,
|
|
required this.url,
|
|
this.fit,
|
|
this.fallback,
|
|
});
|
|
|
|
final String url;
|
|
final BoxFit? fit;
|
|
final Widget? fallback;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final empty = fallback ?? const SizedBox.shrink();
|
|
if (url.isEmpty) return empty;
|
|
final base = ref.watch(serverUrlProvider).value;
|
|
final resolved = _resolve(base, url);
|
|
if (resolved == null) return empty;
|
|
// Cover endpoints are gated by RequireUser server-side. Image.network
|
|
// doesn't carry cookies/Bearer tokens automatically the way the
|
|
// browser's <img> tag does, so we forward the session token as a
|
|
// header. Without this, the server returns 401 and the image fails.
|
|
//
|
|
// 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) {
|
|
final parsed = Uri.tryParse(url);
|
|
if (parsed != null && parsed.hasScheme) return url;
|
|
if (baseUrl == null || baseUrl.isEmpty) return null;
|
|
final base = baseUrl.endsWith('/')
|
|
? baseUrl.substring(0, baseUrl.length - 1)
|
|
: baseUrl;
|
|
final path = url.startsWith('/') ? url : '/$url';
|
|
return '$base$path';
|
|
}
|
|
}
|