import 'dart:async'; import 'dart:convert'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:http/http.dart' as http; import 'package:just_audio/just_audio.dart'; import 'generated/app_config.dart'; import 'live_info_business_object.dart'; class KryzAudioHandler extends BaseAudioHandler with SeekHandler { KryzAudioHandler() { mediaItem.add(_defaultMediaItem); _playerStateSubscription = _player.playerStateStream.listen( _onPlayerStateChanged, ); } static const String streamUrl = AppConfig.streamUrl; static const String mediaId = 'kryz-live-stream'; static const String _liveInfoEndpoint = AppConfig.metadataUrl ?? ''; static const Duration _liveInfoTimeout = Duration(seconds: 8); static const Duration _metadataRefreshInterval = Duration(seconds: 20); static const MediaItem _defaultMediaItem = MediaItem( id: mediaId, title: AppConfig.defaultTitle, artist: AppConfig.defaultArtist, album: 'Live', extras: { 'isLive': true, 'seekable': false, }, ); final AudioPlayer _player = AudioPlayer(); late final StreamSubscription _playerStateSubscription; Timer? _metadataRefreshTimer; bool _isAudioSessionConfigured = false; bool _isSourceLoaded = false; bool _playRequested = false; Future updateMetadataFromLiveInfo(LiveInfoSnapshot? snapshot) async { final current = snapshot?.current; if (current == null || current.type != 'track') { mediaItem.add(_defaultMediaItem); return; } final String subtitle = current.displaySubtitle.trim(); mediaItem.add( MediaItem( id: mediaId, title: current.displayTitle, artist: subtitle.isEmpty ? AppConfig.defaultArtist : subtitle, album: 'Live', extras: { 'isLive': true, 'seekable': false, 'starts': current.starts, 'ends': current.ends, 'schedulerTime': snapshot?.schedulerTime, }, ), ); } @override Future play() async { _playRequested = true; playbackState.add( playbackState.value.copyWith( controls: const [MediaControl.stop], systemActions: const {}, processingState: AudioProcessingState.buffering, playing: false, ), ); await _configureAudioSessionIfNeeded(); try { if (!_isSourceLoaded) { await _player.setAudioSource( AudioSource.uri( Uri.parse(streamUrl), tag: mediaItem.value, ), ); _isSourceLoaded = true; } final session = await AudioSession.instance; await session.setActive(true); await _player.play(); await _refreshMetadataFromApi(); _startMetadataRefreshTimer(); } catch (_) { _playRequested = false; playbackState.add( playbackState.value.copyWith( controls: const [MediaControl.play], systemActions: const {}, processingState: AudioProcessingState.idle, playing: false, ), ); rethrow; } } @override Future stop() async { _playRequested = false; _isSourceLoaded = false; _metadataRefreshTimer?.cancel(); _metadataRefreshTimer = null; await _player.stop(); playbackState.add( playbackState.value.copyWith( controls: const [MediaControl.play], systemActions: const {}, processingState: AudioProcessingState.idle, playing: false, ), ); } @override Future seek(Duration position) async { // Live stream is intentionally non-seekable. } @override Future playFromMediaId(String mediaId, [Map? extras]) { return play(); } @override Future playFromSearch(String query, [Map? extras]) { return play(); } Future dispose() async { _metadataRefreshTimer?.cancel(); await _playerStateSubscription.cancel(); await _player.dispose(); } Future _configureAudioSessionIfNeeded() async { if (_isAudioSessionConfigured) { return; } final session = await AudioSession.instance; await session.configure(const AudioSessionConfiguration.music()); _isAudioSessionConfigured = true; } void _onPlayerStateChanged(PlayerState state) { final AudioProcessingState processingState = switch (state.processingState) { ProcessingState.idle => AudioProcessingState.idle, ProcessingState.loading => AudioProcessingState.loading, ProcessingState.buffering => AudioProcessingState.buffering, ProcessingState.ready => AudioProcessingState.ready, ProcessingState.completed => AudioProcessingState.completed, }; final bool isPlaying = state.playing && state.processingState == ProcessingState.ready; final bool isBuffering = (state.playing && state.processingState != ProcessingState.ready) || (_playRequested && !state.playing && state.processingState != ProcessingState.completed); playbackState.add( playbackState.value.copyWith( controls: [ if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play, ], systemActions: const {}, processingState: isBuffering ? AudioProcessingState.buffering : processingState, playing: isPlaying, ), ); if (isPlaying || isBuffering) { _startMetadataRefreshTimer(); } else { _metadataRefreshTimer?.cancel(); _metadataRefreshTimer = null; } } void _startMetadataRefreshTimer() { if (_metadataRefreshTimer != null) { return; } _metadataRefreshTimer = Timer.periodic(_metadataRefreshInterval, (_) { _refreshMetadataFromApi(); }); } Future _refreshMetadataFromApi() async { try { final response = await http .get(Uri.parse(_liveInfoEndpoint)) .timeout(_liveInfoTimeout); if (response.statusCode < 200 || response.statusCode >= 300) { return; } final dynamic decoded = jsonDecode(response.body); if (decoded is! Map) { return; } final snapshot = LiveInfoSnapshot.fromJson(decoded); await updateMetadataFromLiveInfo(snapshot); } catch (_) { // Keep existing metadata if live-info fetch fails. } } }