Files
minstrel/flutter_client/lib/player/audio_handler.dart
T
bvandeusen 602ef3bfdf 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>
2026-05-08 22:47:25 -04:00

110 lines
3.5 KiB
Dart

import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart';
import '../models/track.dart';
class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandler {
MinstrelAudioHandler() {
_player.playbackEventStream.listen(_broadcastState);
}
final AudioPlayer _player = AudioPlayer();
String _baseUrl = '';
String? _token;
void configure({required String baseUrl, required String? token}) {
_baseUrl = baseUrl;
_token = token;
}
Future<void> setQueueFromTracks(List<TrackRef> tracks, {int initialIndex = 0}) async {
final items = tracks.map(_toMediaItem).toList();
queue.add(items);
if (items.isNotEmpty) {
mediaItem.add(items[initialIndex.clamp(0, items.length - 1)]);
}
final headers = _token == null ? null : {'Authorization': 'Bearer $_token'};
final sources = tracks.map((t) {
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(
sources,
initialIndex: initialIndex,
);
}
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,
artist: t.artistName,
album: t.albumTitle,
duration: Duration(seconds: t.durationSec),
);
@override
Future<void> play() => _player.play();
@override
Future<void> pause() => _player.pause();
@override
Future<void> seek(Duration position) => _player.seek(position);
@override
Future<void> skipToNext() => _player.seekToNext();
@override
Future<void> skipToPrevious() => _player.seekToPrevious();
void _broadcastState(PlaybackEvent event) {
final playing = _player.playing;
playbackState.add(PlaybackState(
controls: [
MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext,
],
systemActions: const {MediaAction.seek},
processingState: switch (_player.processingState) {
ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.buffering => AudioProcessingState.buffering,
ProcessingState.ready => AudioProcessingState.ready,
ProcessingState.completed => AudioProcessingState.completed,
},
playing: playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
queueIndex: event.currentIndex,
));
}
}