235 lines
6.7 KiB
Dart
235 lines
6.7 KiB
Dart
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> pause() async {
|
|
// CarPlay and lock-screen transport may issue pause for live streams.
|
|
// Treat pause as stop to keep behavior consistent across platforms.
|
|
await stop();
|
|
}
|
|
|
|
@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.
|
|
}
|
|
}
|
|
}
|