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//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 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'; } }