import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../auth/auth_provider.dart'; /// CachedNetworkImage wrapper that resolves server-relative URLs (e.g. /// `/api/albums//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 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'} : {}; 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, ); }, ); } 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'; } }