fix(flutter): cover URLs resolve against server baseUrl + audio stream URL guard
Cover-art Image.network calls were passing server-relative URLs (/api/albums/<id>/cover) straight to NetworkImage, which interprets "no scheme" as file:/// and crashes with "No host specified in URI". Same root cause regardless of HTTPS or HTTP server. ServerImage wraps Image.network with a Riverpod read of serverUrlProvider and prefixes the configured base URL. Absolute URLs (e.g. discover screen's Lidarr image_urls) pass through unchanged. Three call sites updated: album_card, artist_card, playlists_list_screen. discover_screen left as-is — its row.imageUrl is already absolute (Lidarr returns full URLs from MusicBrainz / Spotify metadata) and it has a meaningful errorBuilder that ServerImage doesn't expose. Also adds a defensive check in audio_handler.setQueueFromTracks: if the constructed stream URL ends up scheme-less, throw a StateError naming baseUrl + track.streamUrl + track.id instead of letting it fall through to ExoPlayer which surfaces a confusing "Cleartext HTTP traffic to 127.0.0.1 not permitted" error (Android's URL parser defaults a scheme-less URI to localhost). User reported this exact confusing error against an HTTPS prod server; the better message will pinpoint where the empty baseUrl comes from on next reproduction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
return Image.network(resolved, fit: fit);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user