mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-12 23:45:18 +00:00

* this change fixes the garbled audio on my Pixel 6a while the screen is off. Not familiar with libmpv, but seems to favor audiotrack audio output over opensles. KRTirtho/spotube#571 * get audio session id, send it to AudioTrack in libmpv, broadcast it to other apps. Fixes KRTirtho/spotube#1221 --------- Co-authored-by: Kingkor Roy Tirtho <krtirtho@gmail.com>
385 lines
11 KiB
Dart
385 lines
11 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
|
|
import 'package:catcher_2/catcher_2.dart';
|
|
import 'package:collection/collection.dart';
|
|
import 'package:media_kit/media_kit.dart';
|
|
import 'package:flutter_broadcasts/flutter_broadcasts.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:audio_session/audio_session.dart';
|
|
// ignore: implementation_imports
|
|
import 'package:spotube/services/audio_player/playback_state.dart';
|
|
|
|
/// MediaKit [Player] by default doesn't have a state stream.
|
|
/// This class adds a state stream to the [Player] class.
|
|
class MkPlayerWithState extends Player {
|
|
final StreamController<AudioPlaybackState> _playerStateStream;
|
|
final StreamController<Playlist> _playlistStream;
|
|
final StreamController<bool> _shuffleStream;
|
|
final StreamController<PlaylistMode> _loopModeStream;
|
|
|
|
static const String EXTRA_PACKAGE_NAME = "android.media.extra.PACKAGE_NAME";
|
|
static const String EXTRA_AUDIO_SESSION = "android.media.extra.AUDIO_SESSION";
|
|
static const String ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION =
|
|
"android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION";
|
|
static const String ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION =
|
|
"android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION";
|
|
|
|
late final List<StreamSubscription> _subscriptions;
|
|
|
|
bool _shuffled;
|
|
PlaylistMode _loopMode;
|
|
|
|
Playlist? _playlist;
|
|
List<Media>? _tempMedias;
|
|
int _androidAudioSessionId = 0;
|
|
String _packageName = "";
|
|
AndroidAudioManager? _androidAudioManager;
|
|
|
|
MkPlayerWithState({super.configuration})
|
|
: _playerStateStream = StreamController.broadcast(),
|
|
_shuffleStream = StreamController.broadcast(),
|
|
_loopModeStream = StreamController.broadcast(),
|
|
_playlistStream = StreamController.broadcast(),
|
|
_shuffled = false,
|
|
_loopMode = PlaylistMode.none {
|
|
_subscriptions = [
|
|
stream.buffering.listen((event) {
|
|
_playerStateStream.add(AudioPlaybackState.buffering);
|
|
}),
|
|
stream.playing.listen((playing) {
|
|
if (playing) {
|
|
_playerStateStream.add(AudioPlaybackState.playing);
|
|
} else {
|
|
_playerStateStream.add(AudioPlaybackState.paused);
|
|
}
|
|
}),
|
|
stream.completed.listen((isCompleted) async {
|
|
try {
|
|
if (!isCompleted) return;
|
|
|
|
_playerStateStream.add(AudioPlaybackState.completed);
|
|
if (loopMode == PlaylistMode.single) {
|
|
await super.open(_playlist!.medias[_playlist!.index], play: true);
|
|
} else {
|
|
await next();
|
|
await Future.delayed(const Duration(milliseconds: 250), play);
|
|
}
|
|
} catch (e, stackTrace) {
|
|
Catcher2.reportCheckedError(e, stackTrace);
|
|
}
|
|
}),
|
|
stream.playlist.listen((event) {
|
|
if (event.medias.isEmpty) {
|
|
_playerStateStream.add(AudioPlaybackState.stopped);
|
|
}
|
|
}),
|
|
stream.error.listen((event) {
|
|
Catcher2.reportCheckedError('[MediaKitError] \n$event', null);
|
|
}),
|
|
];
|
|
PackageInfo.fromPlatform().then((packageInfo) {
|
|
_packageName = packageInfo.packageName;
|
|
});
|
|
if (DesktopTools.platform.isAndroid) {
|
|
_androidAudioManager = AndroidAudioManager();
|
|
AudioSession.instance.then((s) async {
|
|
_androidAudioSessionId =
|
|
await _androidAudioManager!.generateAudioSessionId();
|
|
notifyAudioSessionUpdate(true);
|
|
|
|
nativePlayer.setProperty(
|
|
"audiotrack-session-id", _androidAudioSessionId.toString());
|
|
nativePlayer.setProperty("ao", "audiotrack,opensles,");
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> notifyAudioSessionUpdate(bool active) async {
|
|
if (DesktopTools.platform.isAndroid) {
|
|
sendBroadcast(BroadcastMessage(
|
|
name: active
|
|
? ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION
|
|
: ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION,
|
|
data: {
|
|
EXTRA_AUDIO_SESSION: _androidAudioSessionId,
|
|
EXTRA_PACKAGE_NAME: _packageName
|
|
}));
|
|
}
|
|
}
|
|
|
|
bool get shuffled => _shuffled;
|
|
PlaylistMode get loopMode => _loopMode;
|
|
Playlist get playlist => _playlist ?? const Playlist([], index: -1);
|
|
|
|
Stream<AudioPlaybackState> get playerStateStream => _playerStateStream.stream;
|
|
Stream<bool> get shuffleStream => _shuffleStream.stream;
|
|
Stream<PlaylistMode> get loopModeStream => _loopModeStream.stream;
|
|
Stream<Playlist> get playlistStream => _playlistStream.stream;
|
|
Stream<int> get indexChangeStream {
|
|
int oldIndex = playlist.index;
|
|
return playlistStream.map((event) => event.index).where((newIndex) {
|
|
if (newIndex != oldIndex) {
|
|
oldIndex = newIndex;
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
set playlist(Playlist playlist) {
|
|
_playlist = playlist;
|
|
_playlistStream.add(playlist);
|
|
}
|
|
|
|
@override
|
|
Future<void> setShuffle(bool shuffle) async {
|
|
_shuffled = shuffle;
|
|
if (shuffle) {
|
|
_tempMedias = _playlist!.medias;
|
|
final active = _playlist!.medias[_playlist!.index];
|
|
final newMedias = _playlist!.medias.toList()
|
|
..shuffle()
|
|
..remove(active)
|
|
..insert(0, active);
|
|
playlist = _playlist!.copyWith(
|
|
medias: newMedias,
|
|
index: newMedias.indexOf(active),
|
|
);
|
|
} else {
|
|
if (_tempMedias == null) return;
|
|
playlist = _playlist!.copyWith(
|
|
medias: _tempMedias!,
|
|
index: _tempMedias?.indexOf(
|
|
_playlist!.medias[_playlist!.index],
|
|
),
|
|
);
|
|
_tempMedias = null;
|
|
}
|
|
await super.setShuffle(shuffle);
|
|
_shuffleStream.add(shuffle);
|
|
}
|
|
|
|
@override
|
|
Future<void> setPlaylistMode(PlaylistMode playlistMode) async {
|
|
_loopMode = playlistMode;
|
|
await super.setPlaylistMode(playlistMode);
|
|
_loopModeStream.add(playlistMode);
|
|
}
|
|
|
|
@override
|
|
Future<void> stop() async {
|
|
await super.stop();
|
|
await pause();
|
|
await seek(Duration.zero);
|
|
|
|
_loopMode = PlaylistMode.none;
|
|
_shuffled = false;
|
|
_playlist = null;
|
|
_tempMedias = null;
|
|
_playerStateStream.add(AudioPlaybackState.stopped);
|
|
_shuffleStream.add(false);
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
for (var element in _subscriptions) {
|
|
element.cancel();
|
|
}
|
|
await notifyAudioSessionUpdate(false);
|
|
return super.dispose();
|
|
}
|
|
|
|
@override
|
|
Future<void> open(
|
|
Playable playable, {
|
|
bool play = true,
|
|
}) async {
|
|
await stop();
|
|
if (playable is Playlist) {
|
|
playlist = playable;
|
|
super.open(playable.medias[playable.index], play: play);
|
|
}
|
|
await super.open(playable, play: play);
|
|
}
|
|
|
|
@override
|
|
Future<void> next() async {
|
|
if (_playlist == null) {
|
|
return;
|
|
}
|
|
|
|
final isLast = _playlist!.index == _playlist!.medias.length - 1;
|
|
|
|
if (isLast) {
|
|
switch (loopMode) {
|
|
case PlaylistMode.loop:
|
|
playlist = _playlist!.copyWith(index: 0);
|
|
super.open(_playlist!.medias[_playlist!.index], play: true);
|
|
break;
|
|
case PlaylistMode.none:
|
|
// Fixes auto-repeating the last track
|
|
await super.stop();
|
|
break;
|
|
default:
|
|
}
|
|
} else {
|
|
playlist = _playlist!.copyWith(index: _playlist!.index + 1);
|
|
|
|
return super.open(_playlist!.medias[_playlist!.index], play: true);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> previous() async {
|
|
if (_playlist == null || _playlist!.index - 1 < 0) return;
|
|
|
|
if (loopMode == PlaylistMode.loop && _playlist!.index == 0) {
|
|
playlist = _playlist!.copyWith(index: _playlist!.medias.length - 1);
|
|
return super.open(_playlist!.medias[_playlist!.index], play: true);
|
|
} else if (_playlist!.index != 0) {
|
|
playlist = _playlist!.copyWith(index: _playlist!.index - 1);
|
|
return super.open(_playlist!.medias[_playlist!.index], play: true);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> jump(int index) async {
|
|
if (_playlist == null || index < 0 || index >= _playlist!.medias.length) {
|
|
return;
|
|
}
|
|
|
|
playlist = _playlist!.copyWith(index: index);
|
|
return super.open(_playlist!.medias[index], play: true);
|
|
}
|
|
|
|
@override
|
|
Future<void> move(int from, int to) async {
|
|
if (_playlist == null ||
|
|
from >= _playlist!.medias.length ||
|
|
to >= _playlist!.medias.length) return;
|
|
|
|
final active = _playlist!.medias[_playlist!.index];
|
|
final newPlaylist = _playlist!.copyWith(
|
|
medias: _playlist!.medias.mapIndexed((index, element) {
|
|
if (index == from) {
|
|
return _playlist!.medias[to];
|
|
} else if (index == to) {
|
|
return _playlist!.medias[from];
|
|
}
|
|
return element;
|
|
}).toList(),
|
|
);
|
|
playlist = _playlist!.copyWith(
|
|
index: newPlaylist.medias.indexOf(active),
|
|
medias: newPlaylist.medias,
|
|
);
|
|
}
|
|
|
|
/// This replaces the old source with a new one
|
|
///
|
|
/// If the old source is playing, the new one will play
|
|
/// from the beginning
|
|
///
|
|
/// This doesn't work when [playlist] is null
|
|
void replace(String oldUrl, String newUrl) {
|
|
if (_playlist == null) {
|
|
return;
|
|
}
|
|
|
|
final isOldUrlPlaying = _playlist!.medias[_playlist!.index].uri == oldUrl;
|
|
|
|
// ends the loop where match is found
|
|
// tends to be a bit more efficient than forEach
|
|
_playlist!.medias.firstWhereIndexedOrNull((i, media) {
|
|
if (media.uri != oldUrl) return false;
|
|
if (isOldUrlPlaying) {
|
|
pause();
|
|
}
|
|
final copyMedias = [..._playlist!.medias];
|
|
copyMedias[i] = Media(newUrl, extras: media.extras);
|
|
playlist = _playlist!.copyWith(medias: copyMedias);
|
|
if (isOldUrlPlaying) {
|
|
super.open(
|
|
copyMedias[i],
|
|
play: true,
|
|
);
|
|
}
|
|
|
|
// replace in the _tempMedias if it's not null
|
|
if (shuffled && _tempMedias != null) {
|
|
final tempIndex = _tempMedias!.indexOf(media);
|
|
_tempMedias![tempIndex] = Media(newUrl, extras: media.extras);
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Future<void> add(Media media) async {
|
|
if (_playlist == null) return;
|
|
|
|
playlist = _playlist!.copyWith(
|
|
medias: [..._playlist!.medias, media],
|
|
);
|
|
|
|
if (shuffled && _tempMedias != null) {
|
|
_tempMedias!.add(media);
|
|
}
|
|
}
|
|
|
|
FutureOr<void> insert(int index, Media media) {
|
|
if (_playlist == null ||
|
|
index < 0 ||
|
|
(_playlist!.medias.length > 1 &&
|
|
index > _playlist!.medias.length - 1)) {
|
|
return null;
|
|
}
|
|
|
|
final newMedias = _playlist!.medias.toList()..insert(index, media);
|
|
|
|
playlist = _playlist!.copyWith(
|
|
medias: newMedias,
|
|
index: newMedias.indexOf(_playlist!.medias[_playlist!.index]),
|
|
);
|
|
|
|
if (shuffled && _tempMedias != null) {
|
|
_tempMedias!.insert(index, media);
|
|
}
|
|
}
|
|
|
|
/// Doesn't work when active media is the one to be removed
|
|
@override
|
|
Future<void> remove(int index) async {
|
|
if (_playlist == null ||
|
|
index < 0 ||
|
|
index > _playlist!.medias.length - 1 ||
|
|
_playlist!.index == index) {
|
|
return;
|
|
}
|
|
|
|
final targetItem = _playlist!.medias.elementAtOrNull(index);
|
|
if (targetItem == null) return;
|
|
|
|
if (shuffled && _tempMedias != null) {
|
|
_tempMedias!.remove(targetItem);
|
|
}
|
|
|
|
final newMedias = _playlist!.medias.toList()..removeAt(index);
|
|
|
|
playlist = _playlist!.copyWith(
|
|
medias: newMedias,
|
|
index: newMedias.indexOf(_playlist!.medias[_playlist!.index]),
|
|
);
|
|
}
|
|
|
|
NativePlayer get nativePlayer => platform as NativePlayer;
|
|
|
|
Future<void> setAudioNormalization(bool normalize) async {
|
|
if (normalize) {
|
|
await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5');
|
|
} else {
|
|
await nativePlayer.setProperty('af', '');
|
|
}
|
|
}
|
|
}
|