f732c49645
Slice 1 of the cover-caching pass. The previous Image.network /
NetworkImage path only cached covers in memory, so a scroll-off + scroll-
back or an app restart re-downloaded every tile from the server. Swap
to cached_network_image so bytes land on disk (path_provider temp dir,
URL-keyed) and survive both.
Sites migrated:
- ServerImage (all /api/*/cover usage — home grid, library, playlist,
artist/album detail headers)
- DiscoverScreen Lidarr suggestion thumbnails
- PlayerBar mini cover (HTTPS branch; file:// branch unchanged since
AlbumCoverCache files are already on disk)
Auth header forwarding preserved via httpHeaders. Fade-in disabled so
populated grids paint instantly on cache hit.
Slice 2 (pre-warm during sync) builds on this same cache manager.
88 lines
3.4 KiB
Dart
88 lines
3.4 KiB
Dart
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/<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,
|
|
// No fadeIn — covers paint instantly once cached, and the
|
|
// default 500ms fade looks like a regression on a populated
|
|
// grid.
|
|
fadeInDuration: Duration.zero,
|
|
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';
|
|
}
|
|
}
|