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:
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import '../../models/album.dart';
|
import '../../models/album.dart';
|
||||||
|
import '../../shared/widgets/server_image.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
|
|
||||||
class AlbumCard extends StatelessWidget {
|
class AlbumCard extends StatelessWidget {
|
||||||
@@ -27,7 +28,7 @@ class AlbumCard extends StatelessWidget {
|
|||||||
color: fs.slate,
|
color: fs.slate,
|
||||||
child: album.coverUrl.isEmpty
|
child: album.coverUrl.isEmpty
|
||||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
? 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),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
import '../../models/artist.dart';
|
import '../../models/artist.dart';
|
||||||
|
import '../../shared/widgets/server_image.dart';
|
||||||
import '../../theme/theme_extension.dart';
|
import '../../theme/theme_extension.dart';
|
||||||
|
|
||||||
class ArtistCard extends StatelessWidget {
|
class ArtistCard extends StatelessWidget {
|
||||||
@@ -26,7 +27,7 @@ class ArtistCard extends StatelessWidget {
|
|||||||
color: fs.slate,
|
color: fs.slate,
|
||||||
child: artist.coverUrl.isEmpty
|
child: artist.coverUrl.isEmpty
|
||||||
? SvgPicture.asset('assets/svg/album-fallback.svg', fit: BoxFit.cover)
|
? 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),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -26,12 +26,21 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
|
|
||||||
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
|
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
|
||||||
final sources = tracks.map((t) {
|
final sources = tracks.map((t) {
|
||||||
final url = t.streamUrl.isNotEmpty
|
final url = _resolveStreamUrl(t);
|
||||||
? (Uri.tryParse(t.streamUrl)?.hasScheme == true
|
// Defensive: a scheme-less URL handed to ExoPlayer crashes with
|
||||||
? t.streamUrl
|
// a confusing "Cleartext HTTP traffic to 127.0.0.1 not permitted"
|
||||||
: '$_baseUrl${t.streamUrl}')
|
// error because Android's URL parser falls back to localhost.
|
||||||
: '$_baseUrl/api/tracks/${t.id}/stream';
|
// Surface the actual URL so debugging is straightforward.
|
||||||
return AudioSource.uri(Uri.parse(url), headers: headers);
|
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();
|
}).toList();
|
||||||
|
|
||||||
await _player.setAudioSources(
|
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(
|
MediaItem _toMediaItem(TrackRef t) => MediaItem(
|
||||||
id: t.id,
|
id: t.id,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
import '../shared/widgets/main_app_bar_actions.dart';
|
import '../shared/widgets/main_app_bar_actions.dart';
|
||||||
|
import '../shared/widgets/server_image.dart';
|
||||||
import '../theme/theme_extension.dart';
|
import '../theme/theme_extension.dart';
|
||||||
import 'playlists_provider.dart';
|
import 'playlists_provider.dart';
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ class _PlaylistTile extends StatelessWidget {
|
|||||||
color: fs.slate,
|
color: fs.slate,
|
||||||
child: playlist.coverUrl.isEmpty
|
child: playlist.coverUrl.isEmpty
|
||||||
? Icon(Icons.queue_music, color: fs.ash)
|
? 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),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -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