refactors design to flutter driven service core.
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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 'live_info_business_object.dart';
|
||||
|
||||
class KryzAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
KryzAudioHandler() {
|
||||
mediaItem.add(_defaultMediaItem);
|
||||
_playerStateSubscription = _player.playerStateStream.listen(
|
||||
_onPlayerStateChanged,
|
||||
);
|
||||
}
|
||||
|
||||
static const String streamUrl = 'https://kryz.out.airtime.pro/kryz_a';
|
||||
static const String mediaId = 'kryz-live-stream';
|
||||
static const String _liveInfoEndpoint = 'http://kryz.airtime.pro/api/live-info';
|
||||
static const Duration _liveInfoTimeout = Duration(seconds: 8);
|
||||
static const Duration _metadataRefreshInterval = Duration(seconds: 20);
|
||||
|
||||
static const MediaItem _defaultMediaItem = MediaItem(
|
||||
id: mediaId,
|
||||
title: 'KRYZ Live Stream',
|
||||
artist: 'KRYZ Radio',
|
||||
album: 'Live',
|
||||
extras: <String, dynamic>{
|
||||
'isLive': true,
|
||||
'seekable': false,
|
||||
},
|
||||
);
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
late final StreamSubscription<PlayerState> _playerStateSubscription;
|
||||
Timer? _metadataRefreshTimer;
|
||||
bool _isAudioSessionConfigured = false;
|
||||
bool _isSourceLoaded = false;
|
||||
bool _playRequested = false;
|
||||
|
||||
Future<void> 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 ? 'KRYZ Radio' : subtitle,
|
||||
album: 'Live',
|
||||
extras: <String, dynamic>{
|
||||
'isLive': true,
|
||||
'seekable': false,
|
||||
'starts': current.starts,
|
||||
'ends': current.ends,
|
||||
'schedulerTime': snapshot?.schedulerTime,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
_playRequested = true;
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
controls: const <MediaControl>[MediaControl.stop],
|
||||
systemActions: const <MediaAction>{},
|
||||
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>[MediaControl.play],
|
||||
systemActions: const <MediaAction>{},
|
||||
processingState: AudioProcessingState.idle,
|
||||
playing: false,
|
||||
),
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_playRequested = false;
|
||||
_isSourceLoaded = false;
|
||||
_metadataRefreshTimer?.cancel();
|
||||
_metadataRefreshTimer = null;
|
||||
await _player.stop();
|
||||
playbackState.add(
|
||||
playbackState.value.copyWith(
|
||||
controls: const <MediaControl>[MediaControl.play],
|
||||
systemActions: const <MediaAction>{},
|
||||
processingState: AudioProcessingState.idle,
|
||||
playing: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
// Live stream is intentionally non-seekable.
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playFromMediaId(String mediaId, [Map<String, dynamic>? extras]) {
|
||||
return play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playFromSearch(String query, [Map<String, dynamic>? extras]) {
|
||||
return play();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_metadataRefreshTimer?.cancel();
|
||||
await _playerStateSubscription.cancel();
|
||||
await _player.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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: <MediaControl>[
|
||||
if (isPlaying || isBuffering) MediaControl.stop else MediaControl.play,
|
||||
],
|
||||
systemActions: const <MediaAction>{},
|
||||
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<void> _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<String, dynamic>) {
|
||||
return;
|
||||
}
|
||||
|
||||
final snapshot = LiveInfoSnapshot.fromJson(decoded);
|
||||
await updateMetadataFromLiveInfo(snapshot);
|
||||
} catch (_) {
|
||||
// Keep existing metadata if live-info fetch fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user