nkbmusic/lib/audio/nk_audio_handler.dart
metatroncubeswdev 5c304ec494 Fix iOS playback: attach the Android-only audio effects only on Android
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:50:41 -04:00

165 lines
5.9 KiB
Dart

import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:just_audio/just_audio.dart';
import 'sound_settings.dart';
/// Owns the [AudioPlayer] and exposes it to the OS (lock screen, notification,
/// headset and Bluetooth buttons) through audio_service.
class NkAudioHandler extends BaseAudioHandler with SeekHandler {
NkAudioHandler() {
_init();
}
/// Sound settings (Android only; iOS has no per-app equalizer in just_audio).
final equalizer = AndroidEqualizer();
final loudness = AndroidLoudnessEnhancer();
// just_audio activates every effect in the pipeline on load, whatever the platform, and the
// Android effects throw on iOS, so attach them only on Android.
late final _player = AudioPlayer(
audioPipeline: AudioPipeline(androidAudioEffects: soundEffectsSupported ? [loudness, equalizer] : const []),
);
Stream<Duration> get positionStream => _player.positionStream;
Stream<Duration?> get durationStream => _player.durationStream;
Future<void> _init() async {
final session = await AudioSession.instance;
await session.configure(const AudioSessionConfiguration.music());
_player.playbackEventStream.listen(
(_) => playbackState.add(_currentState()),
onError: (Object e, StackTrace st) => playbackState.add(
playbackState.value.copyWith(processingState: AudioProcessingState.error, errorMessage: '$e'),
),
);
_player.playingStream.listen((_) => playbackState.add(_currentState()));
_player.shuffleModeEnabledStream.listen((_) => playbackState.add(_currentState()));
_player.loopModeStream.listen((_) => playbackState.add(_currentState()));
_player.sequenceStateStream.listen((state) {
final tag = state.currentSource?.tag;
if (tag is MediaItem) mediaItem.add(tag);
queue.add([
for (final s in state.effectiveSequence)
if (s.tag is MediaItem) s.tag as MediaItem,
]);
});
// Fill in the real duration once the file is loaded.
_player.durationStream.listen((d) {
final item = mediaItem.value;
if (d != null && item != null && item.duration != d) mediaItem.add(item.copyWith(duration: d));
});
}
/// Replaces the queue with [items] and starts playing at [index].
Future<void> playQueue(List<MediaItem> items, {int index = 0}) async {
if (items.isEmpty) return;
await _player.setAudioSources(
[for (final item in items) _sourceFor(item)],
initialIndex: index,
);
await _player.play();
}
/// Inserts [item] right after the current song.
Future<void> playNext(MediaItem item) async {
final source = _sourceFor(item);
final current = _player.currentIndex;
if (current == null) return playQueue([item]);
await _player.insertAudioSource(current + 1, source);
}
@override
Future<void> addQueueItem(MediaItem mediaItem) =>
_player.addAudioSource(_sourceFor(mediaItem));
/// Set at startup: returns the downloaded (encrypted) copy of a song, if there is one.
AudioSource? Function(MediaItem item)? localSource;
AudioSource _sourceFor(MediaItem item) =>
localSource?.call(item) ?? AudioSource.uri(Uri.parse(item.extras!['url'] as String), tag: item);
@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();
/// Restarts the song when more than 3 s in, like every major player.
@override
Future<void> skipToPrevious() async {
if (_player.position > const Duration(seconds: 3) || !_player.hasPrevious) {
return _player.seek(Duration.zero);
}
return _player.seekToPrevious();
}
@override
Future<void> skipToQueueItem(int index) async {
final indices = _player.shuffleModeEnabled ? _player.shuffleIndices : null;
await _player.seek(Duration.zero, index: indices == null ? index : indices[index]);
}
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
final enable = shuffleMode != AudioServiceShuffleMode.none;
if (enable) await _player.shuffle();
await _player.setShuffleModeEnabled(enable);
}
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) => _player.setLoopMode(switch (repeatMode) {
AudioServiceRepeatMode.one => LoopMode.one,
AudioServiceRepeatMode.all || AudioServiceRepeatMode.group => LoopMode.all,
AudioServiceRepeatMode.none => LoopMode.off,
});
@override
Future<void> stop() async {
await _player.stop();
return super.stop();
}
PlaybackState _currentState() {
final playing = _player.playing;
return PlaybackState(
controls: [
MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.skipToNext,
],
systemActions: const {MediaAction.seek, MediaAction.seekForward, MediaAction.seekBackward},
androidCompactActionIndices: const [0, 1, 2],
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: _player.currentIndex,
shuffleMode: _player.shuffleModeEnabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none,
repeatMode: switch (_player.loopMode) {
LoopMode.one => AudioServiceRepeatMode.one,
LoopMode.all => AudioServiceRepeatMode.all,
LoopMode.off => AudioServiceRepeatMode.none,
},
);
}
}