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 setQueueFromTracks(List 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 play() => _player.play(); @override Future pause() => _player.pause(); @override Future seek(Duration position) => _player.seek(position); @override Future skipToNext() => _player.seekToNext(); @override Future 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, )); } }