Files

105 lines
4.3 KiB
Dart

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'
show HttpExceptionWithStatus;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../auth/auth_provider.dart';
/// CachedNetworkImage 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.
///
/// Uses CachedNetworkImage so cover bytes land on disk (path_provider
/// temp dir, keyed by URL) and survive scroll-off + app restart. The
/// previous Image.network behavior cached only in-memory, so the cold-
/// start home grid re-downloaded every cover.
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. The image
// loader 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 mounted the image with httpHeaders:null at that
// moment, the disk cache would lock in the 401 response and never
// retry. 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'}
: <String, String>{};
return CachedNetworkImage(
imageUrl: resolved,
httpHeaders: headers,
fit: fit,
// 120ms feels like cover bytes settling in on a cache miss
// (smoother than the abrupt zero-fade); on cache hits the
// image is decoded synchronously so the fade is imperceptible.
// The default 500ms is too long — looks like a regression on
// a populated grid.
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: Duration.zero,
// Keep failures local — a single 401/timeout shouldn't dump
// a stack trace or replace the parent Container's background.
errorWidget: (_, __, ___) => empty,
// Filter expected 404s out of dev console noise: playlist
// collages aren't built until the playlist has tracks and
// the build job runs (system playlists like For-You /
// Discover hit this on first-render). The errorWidget
// already renders the fallback for the user; this just
// keeps the dev console clean. Non-404 errors still
// surface so auth/connectivity issues remain visible.
errorListener: (err) {
if (err is HttpExceptionWithStatus && err.statusCode == 404) {
return;
}
debugPrint('ServerImage: $err');
},
);
},
);
}
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';
}
}