83df3773ae
Two issues from on-device testing against prod HTTPS: 1. ServerImage was resolving cover URLs correctly (https://minstrel.fabledsword.com/api/albums/.../cover) but the server returned 401 because Image.network doesn't carry the session token automatically the way the browser sends cookies. Forwards the stored session token as an Authorization: Bearer header. No-op when no token is present. 2. The "Cleartext HTTP traffic to 127.0.0.1" audio error reproduced even after the previous defensive check landed, which means the URL handed to ExoPlayer has a valid scheme+host (just the wrong host). The check only catches scheme-less URLs, so it didn't fire. Added debugPrint logging at configure() and setQueueFromTracks() time to show the actual baseUrl + per-track resolved URL on the next reproduction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
3.8 KiB
Dart
117 lines
3.8 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/foundation.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;
|
|
debugPrint('audio_handler.configure: baseUrl="$baseUrl" '
|
|
'tokenPresent=${token != null && token.isNotEmpty}');
|
|
}
|
|
|
|
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'};
|
|
debugPrint('audio_handler.setQueueFromTracks: '
|
|
'_baseUrl="$_baseUrl" trackCount=${tracks.length}');
|
|
final sources = tracks.map((t) {
|
|
final url = _resolveStreamUrl(t);
|
|
debugPrint('audio_handler: track.id=${t.id} '
|
|
'track.streamUrl="${t.streamUrl}" → resolved="$url"');
|
|
// 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,
|
|
));
|
|
}
|
|
}
|