From 602ef3bfdff84391dfa678d2487ccc6116e00150 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 22:47:25 -0400 Subject: [PATCH] fix(flutter): cover URLs resolve against server baseUrl + audio stream URL guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover-art Image.network calls were passing server-relative URLs (/api/albums//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) --- .../lib/library/widgets/album_card.dart | 3 +- .../lib/library/widgets/artist_card.dart | 3 +- flutter_client/lib/player/audio_handler.dart | 32 ++++++++++--- .../lib/playlists/playlists_list_screen.dart | 3 +- .../lib/shared/widgets/server_image.dart | 47 +++++++++++++++++++ 5 files changed, 79 insertions(+), 9 deletions(-) create mode 100644 flutter_client/lib/shared/widgets/server_image.dart diff --git a/flutter_client/lib/library/widgets/album_card.dart b/flutter_client/lib/library/widgets/album_card.dart index 84915b29..7f844ef2 100644 --- a/flutter_client/lib/library/widgets/album_card.dart +++ b/flutter_client/lib/library/widgets/album_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../models/album.dart'; +import '../../shared/widgets/server_image.dart'; import '../../theme/theme_extension.dart'; class AlbumCard extends StatelessWidget { @@ -27,7 +28,7 @@ class AlbumCard extends StatelessWidget { color: fs.slate, child: album.coverUrl.isEmpty ? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover) - : Image.network(album.coverUrl, fit: BoxFit.cover), + : ServerImage(url: album.coverUrl, fit: BoxFit.cover), ), ), const SizedBox(height: 8), diff --git a/flutter_client/lib/library/widgets/artist_card.dart b/flutter_client/lib/library/widgets/artist_card.dart index 8c17b5ef..526dff9f 100644 --- a/flutter_client/lib/library/widgets/artist_card.dart +++ b/flutter_client/lib/library/widgets/artist_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../models/artist.dart'; +import '../../shared/widgets/server_image.dart'; import '../../theme/theme_extension.dart'; class ArtistCard extends StatelessWidget { @@ -26,7 +27,7 @@ class ArtistCard extends StatelessWidget { color: fs.slate, child: artist.coverUrl.isEmpty ? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover) - : Image.network(artist.coverUrl, fit: BoxFit.cover), + : ServerImage(url: artist.coverUrl, fit: BoxFit.cover), ), ), const SizedBox(height: 8), diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 3e0a86ef..657e5b4c 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -26,12 +26,21 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl final headers = _token == null ? null : {'Authorization': 'Bearer $_token'}; final sources = tracks.map((t) { - final url = t.streamUrl.isNotEmpty - ? (Uri.tryParse(t.streamUrl)?.hasScheme == true - ? t.streamUrl - : '$_baseUrl${t.streamUrl}') - : '$_baseUrl/api/tracks/${t.id}/stream'; - return AudioSource.uri(Uri.parse(url), headers: headers); + final url = _resolveStreamUrl(t); + // Defensive: a scheme-less URL handed to ExoPlayer crashes with + // a confusing "Cleartext HTTP traffic to 127.0.0.1 not permitted" + // error because Android's URL parser falls back to localhost. + // Surface the actual URL so debugging is straightforward. + final parsed = Uri.parse(url); + if (!parsed.hasScheme || parsed.host.isEmpty) { + throw StateError( + 'audio_handler: refused to play scheme-less URL "$url" ' + '(baseUrl="$_baseUrl", track.streamUrl="${t.streamUrl}", ' + 'track.id="${t.id}"). configure() must be called with a ' + 'non-empty baseUrl before setQueueFromTracks().', + ); + } + return AudioSource.uri(parsed, headers: headers); }).toList(); await _player.setAudioSources( @@ -40,6 +49,17 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl ); } + String _resolveStreamUrl(TrackRef t) { + if (t.streamUrl.isEmpty) { + return '$_baseUrl/api/tracks/${t.id}/stream'; + } + final parsed = Uri.tryParse(t.streamUrl); + if (parsed != null && parsed.hasScheme) { + return t.streamUrl; + } + return '$_baseUrl${t.streamUrl}'; + } + MediaItem _toMediaItem(TrackRef t) => MediaItem( id: t.id, title: t.title, diff --git a/flutter_client/lib/playlists/playlists_list_screen.dart b/flutter_client/lib/playlists/playlists_list_screen.dart index e34cc962..55614b93 100644 --- a/flutter_client/lib/playlists/playlists_list_screen.dart +++ b/flutter_client/lib/playlists/playlists_list_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../models/playlist.dart'; import '../shared/widgets/main_app_bar_actions.dart'; +import '../shared/widgets/server_image.dart'; import '../theme/theme_extension.dart'; import 'playlists_provider.dart'; @@ -81,7 +82,7 @@ class _PlaylistTile extends StatelessWidget { color: fs.slate, child: playlist.coverUrl.isEmpty ? Icon(Icons.queue_music, color: fs.ash) - : Image.network(playlist.coverUrl, fit: BoxFit.cover), + : ServerImage(url: playlist.coverUrl, fit: BoxFit.cover), ), ), const SizedBox(width: 12), diff --git a/flutter_client/lib/shared/widgets/server_image.dart b/flutter_client/lib/shared/widgets/server_image.dart new file mode 100644 index 00000000..6559ac9d --- /dev/null +++ b/flutter_client/lib/shared/widgets/server_image.dart @@ -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//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'; + } +}