freezer/lib/api/player.dart

1024 lines
34 KiB
Dart

import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:equalizer/equalizer.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/deezer_audio_source.dart';
import 'package:freezer/api/offline_audio_source.dart';
import 'package:freezer/api/url_audio_source.dart';
import 'package:freezer/ui/android_auto.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:just_audio/just_audio.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:freezer/translations.i18n.dart';
import 'package:collection/collection.dart';
import 'package:scrobblenaut/scrobblenaut.dart';
import 'package:rxdart/rxdart.dart';
import 'definitions.dart';
import '../settings.dart';
import 'dart:io';
import 'dart:async';
import 'dart:convert';
PlayerHelper playerHelper = PlayerHelper();
late AudioHandler audioHandler;
class PlayerHelper {
late StreamSubscription _customEventSubscription;
late StreamSubscription _mediaItemSubscription;
late StreamSubscription _playbackStateStreamSubscription;
QueueSource? queueSource;
AudioServiceRepeatMode repeatType = AudioServiceRepeatMode.none;
Timer? _timer;
int? audioSession;
int? _prevAudioSession;
bool equalizerOpen = false;
bool _shuffleEnabled = false;
int _queueIndex = 0;
bool _started = false;
//Visualizer
// StreamController _visualizerController = StreamController.broadcast();
// Stream get visualizerStream => _visualizerController.stream;
final _streamInfoSubject = BehaviorSubject<StreamQualityInfo>();
ValueStream<StreamQualityInfo> get streamInfo => _streamInfoSubject.stream;
/// Find queue index by id
///
/// The function gets more expensive the longer the queue is and the further the element is from the beginning.
int getQueueIndexFromId() => audioHandler.mediaItem.value == null
? -1
: audioHandler.queue.value
.indexWhere((mi) => mi.id == audioHandler.mediaItem.value!.id);
int getQueueIndex() =>
audioHandler.playbackState.value.queueIndex ?? getQueueIndexFromId();
int get queueIndex => _queueIndex;
Future<void> initAudioHandler() async {
// initialize our audiohandler instance
audioHandler = await AudioService.init(
builder: () => AudioPlayerTask(
ignoreInterruptions: settings.ignoreInterruptions,
deezerAPI: deezerAPI),
config: AudioServiceConfig(
notificationColor: settings.primaryColor,
androidStopForegroundOnPause: false,
androidNotificationOngoing: false,
androidNotificationClickStartsActivity: true,
androidNotificationChannelDescription: 'Freezer',
androidNotificationChannelName: 'Freezer',
androidNotificationIcon: 'drawable/ic_logo',
preloadArtwork: false,
),
);
}
Future<void> start() async {
if (_started) return;
_started = true;
//Subscribe to custom events
_customEventSubscription = audioHandler.customEvent.listen((event) async {
if (!(event is Map)) return;
Logger('PlayerHelper').fine("event received: " + event['action']);
switch (event['action']) {
case 'onLoad':
//After audio_service is loaded, load queue, set quality
await settings.updateAudioServiceQuality();
await audioHandler.customAction('load');
await authorizeLastFM();
break;
case 'onRestore':
//Load queueSource from isolate
queueSource = event['queueSource'] as QueueSource;
repeatType = event['repeatMode'] as AudioServiceRepeatMode;
_queueIndex = getQueueIndex();
break;
case 'screenAndroidAuto':
List<MediaItem> data = await androidAuto.getScreen(event['id']);
await audioHandler.customAction('screenAndroidAuto', {'value': data});
break;
case 'tracksAndroidAuto':
await androidAuto.playItem(event['id']);
break;
case 'audioSession':
if (!settings.enableEqualizer) break;
//Save
_prevAudioSession = audioSession;
audioSession = event['id'];
if (audioSession == null) break;
//Open EQ
if (!equalizerOpen) {
Equalizer.open(event['id']);
equalizerOpen = true;
break;
}
//Change session id
if (_prevAudioSession != audioSession) {
if (_prevAudioSession != null)
Equalizer.removeAudioSessionId(_prevAudioSession!);
Equalizer.setAudioSessionId(audioSession!);
}
break;
//Visualizer data
// case 'visualizer':
// _visualizerController.add(event['data']);
// break;
case 'streamInfo':
Logger('PlayerHelper').fine("streamInfo received");
_streamInfoSubject.add(event['data'] as StreamQualityInfo);
break;
}
});
_mediaItemSubscription = audioHandler.mediaItem.listen((mediaItem) async {
if (mediaItem == null) return;
_queueIndex = getQueueIndex();
//Load more flow if last song (not using .last since it iterates through previous elements first)
//Save queue
await audioHandler.customAction('saveQueue', {});
//Add to history
if (cache.history.length > 0 && cache.history.last.id == mediaItem.id)
return;
cache.history.add(Track.fromMediaItem(mediaItem));
cache.save();
});
//Logging listen timer
_timer = Timer.periodic(Duration(seconds: 2), (timer) async {
if (audioHandler.mediaItem.value == null ||
!audioHandler.playbackState.value.playing) return;
if (audioHandler.playbackState.value.position.inSeconds >
(audioHandler.mediaItem.value!.duration!.inSeconds * 0.75)) {
if (cache.loggedTrackId == audioHandler.mediaItem.value!.id) return;
cache.loggedTrackId = audioHandler.mediaItem.value!.id;
await cache.save();
//Log to Deezer
if (settings.logListen) {
deezerAPI.logListen(audioHandler.mediaItem.value!.id);
}
}
});
//Start audio_service
// await startService(); it is already ready, there is no need to start it
}
Future authorizeLastFM() async {
if (settings.lastFMUsername == null || settings.lastFMPassword == null)
return;
await audioHandler.customAction('authorizeLastFM', {
'username': settings.lastFMUsername,
'password': settings.lastFMPassword
});
}
Future<bool> toggleShuffle() async {
await audioHandler.setShuffleMode((_shuffleEnabled = !_shuffleEnabled)
? AudioServiceShuffleMode.all
: AudioServiceShuffleMode.none);
return _shuffleEnabled;
}
bool get shuffleEnabled => _shuffleEnabled;
//Repeat toggle
Future changeRepeat() async {
//Change to next repeat type
repeatType = repeatType == AudioServiceRepeatMode.all
? AudioServiceRepeatMode.none
: repeatType == AudioServiceRepeatMode.none
? AudioServiceRepeatMode.one
: AudioServiceRepeatMode.all;
//Set repeat type
await audioHandler.setRepeatMode(repeatType);
}
//Executed before exit
Future onExit() async {
_customEventSubscription.cancel();
_playbackStateStreamSubscription.cancel();
_mediaItemSubscription.cancel();
}
//Replace queue, play specified track id
Future<void> _loadQueuePlay(List<MediaItem> queue, String? trackId) async {
await settings.updateAudioServiceQuality();
await audioHandler.customAction('setIndex', {
'index': trackId == null ? 0 : queue.indexWhere((m) => m.id == trackId)
});
await audioHandler.updateQueue(queue);
// if (queue[0].id != trackId)
// await AudioService.skipToQueueItem(trackId);
if (!audioHandler.playbackState.value.playing) audioHandler.play();
}
//Play track from album
Future playFromAlbum(Album album, [String? trackId]) async {
await playFromTrackList(album.tracks!, trackId,
QueueSource(id: album.id, text: album.title, source: 'album'));
}
//Play mix by track
Future playMix(String trackId, String trackTitle) async {
List<Track> tracks = (await deezerAPI.playMix(trackId))!;
playFromTrackList(
tracks,
tracks[0].id,
QueueSource(
id: trackId,
text: 'Mix based on'.i18n + ' $trackTitle',
source: 'mix'));
}
//Play from artist top tracks
Future playFromTopTracks(
List<Track> tracks, String trackId, Artist artist) async {
await playFromTrackList(
tracks,
trackId,
QueueSource(
id: artist.id, text: 'Top ${artist.name}', source: 'topTracks'));
}
Future playFromPlaylist(Playlist playlist, [String? trackId]) async {
print('starting playback from playlist');
await playFromTrackList(playlist.tracks!, trackId,
QueueSource(id: playlist.id, text: playlist.title, source: 'playlist'));
}
//Play episode from show, load whole show as queue
Future<void> playShowEpisode(Show show, List<ShowEpisode> episodes,
{int index = 0}) async {
QueueSource queueSource =
QueueSource(id: show.id, text: show.name, source: 'show');
//Generate media items
List<MediaItem> queue =
episodes.map<MediaItem>((e) => e.toMediaItem(show)).toList();
//Load and play
// await startService(); // audioservice is ready
await settings.updateAudioServiceQuality();
await setQueueSource(queueSource);
await audioHandler.customAction('setIndex', {'index': index});
await audioHandler.updateQueue(queue);
if (!audioHandler.playbackState.value.playing) audioHandler.play();
}
//Load tracks as queue, play track id, set queue source
Future playFromTrackList(
List<Track?> tracks, String? trackId, QueueSource queueSource) async {
final queue = await Future.wait(tracks
.map<Future<MediaItem>>((track) => track!.toMediaItem())
.toList());
await setQueueSource(queueSource);
await _loadQueuePlay(queue, trackId);
}
//Load smart track list as queue, start from beginning
Future playFromSmartTrackList(SmartTrackList stl) async {
//Load from API if no tracks
if (stl.tracks == null || stl.tracks!.isEmpty) {
if (settings.offlineMode) {
Fluttertoast.showToast(
msg: "Offline mode, can't play flow or smart track lists.".i18n,
gravity: ToastGravity.BOTTOM,
toastLength: Toast.LENGTH_SHORT);
return;
}
//Flow songs cannot be accessed by smart track list call
if (stl.id! == 'flow') {
stl.tracks = await deezerAPI.flow(stl.flowConfig);
} else {
stl = await deezerAPI.smartTrackList(stl.id);
}
}
QueueSource queueSource = QueueSource(
id: stl.flowConfig ?? stl.id,
source: (stl.id == 'flow') ? 'flow' : 'smarttracklist',
text: stl.title ??
((stl.id == 'flow') ? 'Flow'.i18n : 'Smart track list'.i18n));
await playFromTrackList(stl.tracks!, stl.tracks![0].id, queueSource);
}
Future setQueueSource(QueueSource queueSource) async {
this.queueSource = queueSource;
await audioHandler.customAction('queueSource', queueSource.toJson());
}
//Reorder tracks in queue
Future reorder(int oldIndex, int newIndex) => audioHandler
.customAction('reorder', {'oldIndex': oldIndex, 'newIndex': newIndex});
//Start visualizer
// Future startVisualizer() async {
// await audioHandler.customAction('startVisualizer');
// }
//Stop visualizer
// Future stopVisualizer() async {
// await audioHandler.customAction('stopVisualizer');
// }
}
class AudioPlayerTask extends BaseAudioHandler {
late AudioPlayer _player;
late DeezerAPI _deezerAPI;
//Queue
List<MediaItem>? _originalQueue;
int _queueIndex = 0;
bool _isInitialized = false;
late ConcatenatingAudioSource _audioSource;
Seeker? _seeker;
//Stream subscriptions
StreamSubscription? _eventSub;
StreamSubscription? _audioSessionSub;
StreamSubscription? _visualizerSubscription;
//Loaded from file/frontendjust
int? mobileQuality;
int? wifiQuality;
QueueSource? queueSource;
Duration? _lastPosition;
AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none;
Completer<List<MediaItem>>? _androidAutoCallback;
Scrobblenaut? _scrobblenaut;
// Last logged track id
String? _loggedTrackId;
MediaItem get currentMediaItem => queue.value[_queueIndex];
late final LazyBox _box;
AudioPlayerTask(
{bool ignoreInterruptions = false, required DeezerAPI deezerAPI}) {
_deezerAPI = deezerAPI;
unawaited(_init(ignoreInterruptions));
}
Future<void> _init(bool ignoreInterruptions) async {
final session = await AudioSession.instance;
session.configure(AudioSessionConfiguration.music());
_box = await Hive.openLazyBox('playback',
path: (await getExternalCacheDirectories())?[0].path ??
(await getExternalStorageDirectory())?.path);
if (ignoreInterruptions) {
_player = AudioPlayer(handleInterruptions: false);
session.interruptionEventStream.listen((_) {});
session.becomingNoisyEventStream.listen((_) {});
} else {
_player = AudioPlayer();
}
//Update track index
_player.currentIndexStream.listen((index) {
if (index != null && queue.value.isNotEmpty) {
_queueIndex = index;
mediaItem.add(currentMediaItem);
}
if (index == queue.value.length - 1) {
unawaited(_onQueueEnd());
}
});
//Update state on all clients on change
_eventSub = _player.playbackEventStream.listen((event) {
//Quality string
if (_queueIndex != -1 && _queueIndex < queue.value.length) {
Map extras = currentMediaItem.extras!;
extras['qualityString'] = '';
queue.value[_queueIndex] =
currentMediaItem.copyWith(extras: extras as Map<String, dynamic>?);
}
//Update
_broadcastState();
});
_player.processingStateStream.listen((state) {
switch (state) {
case ProcessingState.completed:
//Player ended, get more songs
if (_queueIndex == queue.value.length - 1)
customEvent.add({
'action': 'queueEnd',
'queueSource': (queueSource ?? QueueSource()).toJson()
});
break;
default:
break;
}
});
//Audio session
_audioSessionSub = _player.androidAudioSessionIdStream.listen((event) {
customEvent.add({'action': 'audioSession', 'id': event});
});
//Load queue
// queue.add(_queue);
customEvent.add({'action': 'onLoad'});
}
@override
Future skipToQueueItem(int index) async {
_lastPosition = null;
//Skip in player
await _player.seek(Duration.zero, index: index);
_queueIndex = index;
play();
}
@override
Future play() async {
_player.play();
//Restore position on play
if (_lastPosition != null) {
seek(_lastPosition);
_lastPosition = null;
}
//LastFM
if (_queueIndex >= queue.value.length) return;
if (_scrobblenaut != null && currentMediaItem.id != _loggedTrackId) {
_loggedTrackId = currentMediaItem.id;
await _scrobblenaut!.track.scrobble(
track: currentMediaItem.title,
artist: currentMediaItem.artist!,
album: currentMediaItem.album,
);
}
}
@override
Future pause() => _player.pause();
@override
Future seek(Duration? pos) => _player.seek(pos);
@override
Future<void> fastForward() =>
_seekRelative(AudioService.config.fastForwardInterval);
@override
Future<void> rewind() => _seekRelative(-AudioService.config.rewindInterval);
@override
Future<void> seekForward(bool begin) async => _seekContinuously(begin, 1);
@override
Future<void> seekBackward(bool begin) async => _seekContinuously(begin, -1);
//Remove item from queue
@override
Future<void> removeQueueItem(MediaItem mediaItem) async {
int index = queue.value.indexWhere((m) => m.id == mediaItem.id);
removeQueueItemAt(index);
}
@override
Future<void> removeQueueItemAt(int index) async {
if (index <= _queueIndex) {
_queueIndex--;
}
await _audioSource.removeAt(index);
queue.add(queue.value..removeAt(index));
}
@override
Future<void> skipToNext() async {
_lastPosition = null;
if (_queueIndex == queue.value.length - 1) return;
//Update buffering state
_queueIndex++;
await _player.seekToNext();
_broadcastState();
}
@override
Future<void> skipToPrevious() async {
if (_queueIndex == 0) return;
//Update buffering state
//_skipState = AudioProcessingState.skippingToPrevious;
//Normal skip to previous
_queueIndex--;
await _player.seekToPrevious();
//_skipState = null;
}
@override
Future<List<MediaItem>> getChildren(String parentMediaId,
[Map<String, dynamic>? options]) async {
customEvent.add({'action': 'screenAndroidAuto', 'id': parentMediaId});
//Wait for data from main thread
_androidAutoCallback = Completer<List<MediaItem>>();
final data = await _androidAutoCallback!.future;
_androidAutoCallback = null;
return data;
}
//While seeking, jump 10s every 1s
void _seekContinuously(bool begin, int direction) {
_seeker?.stop();
if (begin) {
_seeker = Seeker(_player, Duration(seconds: 10 * direction),
Duration(seconds: 1), currentMediaItem)
..start();
}
}
//Relative seek
Future _seekRelative(Duration offset) async {
Duration newPos = _player.position + offset;
//Out of bounds check
if (newPos < Duration.zero) newPos = Duration.zero;
if (newPos > currentMediaItem.duration!)
newPos = currentMediaItem.duration!;
await _player.seek(newPos);
}
//Update state on all clients
void _broadcastState() {
playbackState.add(PlaybackState(
controls: queueSource?.source != 'show'
? [
/*if (_queueIndex != 0)*/ MediaControl.skipToPrevious,
_player.playing ? MediaControl.pause : MediaControl.play,
/*if (_queueIndex != _queue!.length - 1)*/ MediaControl
.skipToNext,
//Stop
MediaControl(
androidIcon: 'drawable/ic_action_stop',
label: 'stop',
action: MediaAction.stop),
// i mean, the user can just swipe the notification away to stop
]
: [
_player.playing ? MediaControl.pause : MediaControl.play,
MediaControl.fastForward,
MediaControl.rewind,
MediaControl(
androidIcon: 'drawable/ic_action_stop',
label: 'stop',
action: MediaAction.stop),
],
systemActions: queueSource?.source != 'show'
? const {
MediaAction.seek,
MediaAction.seekForward,
MediaAction.seekBackward,
MediaAction.stop
}
: const {
MediaAction.seek,
MediaAction.fastForward,
MediaAction.rewind,
MediaAction.stop,
},
processingState: _convertProcessingState(_player.processingState),
playing: _player.playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
queueIndex: _queueIndex,
));
}
//just_audio state -> audio_service state. If skipping, use _skipState
AudioProcessingState _convertProcessingState(
ProcessingState processingState) {
return const <ProcessingState, AudioProcessingState>{
ProcessingState.idle: AudioProcessingState.idle,
ProcessingState.loading: AudioProcessingState.loading,
ProcessingState.buffering: AudioProcessingState.buffering,
ProcessingState.ready: AudioProcessingState.ready,
ProcessingState.completed: AudioProcessingState.completed,
}[processingState]!;
}
//Replace current queue
@override
Future updateQueue(List<MediaItem> q) async {
_lastPosition = null;
//just_audio
_originalQueue = null;
_player.stop();
if (_isInitialized) _audioSource.clear();
// broadcast to ui
queue.add(q);
//Load
await _loadQueue();
//await _player.seek(Duration.zero, index: 0);
}
//Load queue to just_audio
Future _loadQueue() async {
//Don't reset queue index by starting player
if (!queue.hasValue || queue.value.isEmpty) {
return;
}
final sources = await Future.wait(queue.value.map(_mediaItemToAudioSource));
_audioSource = ConcatenatingAudioSource(children: sources);
//Load in just_audio
try {
await _player.setAudioSource(_audioSource,
initialIndex: _queueIndex, initialPosition: Duration.zero);
} catch (e) {
//Error loading tracks
}
if (_queueIndex != -1) {
mediaItem.add(currentMediaItem);
}
}
Future<AudioSource> _mediaItemToAudioSource(MediaItem mediaItem) async {
//Check if offline
if (Platform.isAndroid) {
String _offlinePath =
p.join((await getExternalStorageDirectory())!.path, 'offline/');
File file = File(p.join(_offlinePath, mediaItem.id));
if (await file.exists()) {
// return Uri.file(f.path);
//return f.path;
//Stream server URL
// return 'http://localhost:36958/?id=${mediaItem.id}';
// maybe still use [AudioSource.file], but find another way to broadcast stream info so to have less overhead
return OfflineAudioSource(file,
onStreamObtained: (streamInfo) =>
customEvent.add({'action': 'streamInfo', 'data': streamInfo}));
// return AudioSource.uri(
// Uri.http('localhost:36958', '/', {'id': mediaItem.id}));
}
}
//Show episode direct link
if (mediaItem.extras!['showUrl'] != null)
return UrlAudioSource(
Uri.parse(mediaItem.extras!['showUrl']),
onStreamObtained: (qualityInfo) =>
customEvent.add({'action': 'streamInfo', 'data': qualityInfo}),
);
//Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer
//This just returns fake url that contains metadata
List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']);
//Quality
ConnectivityResult conn = await Connectivity().checkConnectivity();
int? quality = mobileQuality;
if (conn == ConnectivityResult.wifi) quality = wifiQuality;
if ((playbackDetails ?? []).length < 2)
throw Exception('not enough playback details');
//String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}';
// final uri = Uri.http('localhost:36958', '', {
// if (quality != null) 'q': quality.toString(),
// 'mv': playbackDetails![1],
// 'md5origin': playbackDetails[0],
// 'id': mediaItem.id,
// });
// print("url for " + mediaItem.title + ": " + uri.toString());
// return AudioSource.uri(uri);
// DON'T use the java backend anymore, useless.
return DeezerAudioSource(
quality: quality!,
trackId: mediaItem.id,
md5origin: playbackDetails![0],
mediaVersion: playbackDetails[1],
onStreamObtained: (qualityInfo) =>
customEvent.add({'action': 'streamInfo', 'data': qualityInfo}),
);
}
//Custom actions
@override
Future customAction(String name, [Map<String, dynamic>? args]) async {
switch (name) {
case 'updateQuality':
//Pass wifi & mobile quality by custom action
//Isolate can't access globals
wifiQuality = args!['wifiQuality'];
mobileQuality = args['mobileQuality'];
break;
//Update queue source
case 'queueSource':
queueSource = QueueSource.fromJson(args!);
break;
//Looping
// case 'repeatType':
// _loopMode = LoopMode.values[args!['type']];
// _player.setLoopMode(_loopMode);
// break;
//Save queue
case 'saveQueue':
await _saveQueue();
break;
//Load queue after some initialization in frontend
case 'load':
await _loadQueueFile();
break;
//Android audio callback
case 'screenAndroidAuto':
_androidAutoCallback?.complete((args!['value'] as List<MediaItem>?));
break;
//Reorder tracks, args = [old, new]
case 'reorder':
final oldIndex = args!['oldIndex']! as int;
final newIndex = args['newIndex']! as int;
await _audioSource.move(
oldIndex, oldIndex > newIndex ? newIndex : newIndex - 1);
queue.add(List.from(queue.value)..reorder(oldIndex, newIndex));
_broadcastState();
break;
//Set index without affecting playback for loading
case 'setIndex':
_queueIndex = args!['index'];
break;
//Start visualizer
// case 'startVisualizer':
// if (_visualizerSubscription != null) break;
// _player.startVisualizer(
// enableWaveform: false,
// enableFft: true,
// captureRate: 15000,
// captureSize: 128);
// _visualizerSubscription = _player.visualizerFftStream.listen((event) {
// //Calculate actual values
// List<double> out = [];
// for (int i = 0; i < event.length / 2; i++) {
// int rfk = event[i * 2].toSigned(8);
// int ifk = event[i * 2 + 1].toSigned(8);
// out.add(log(hypot(rfk, ifk) + 1) / 5.2);
// }
// AudioServiceBackground.sendCustomEvent(
// {"action": "visualizer", "data": out});
// });
// break;
// //Stop visualizer
// case 'stopVisualizer':
// if (_visualizerSubscription != null) {
// _visualizerSubscription.cancel();
// _visualizerSubscription = null;
// }
// break;
//Authorize lastfm
case 'authorizeLastFM':
final username = args!['username']! as String;
final password = args['password']! as String;
try {
final lastFM = await LastFM.authenticateWithPasswordHash(
apiKey: 'b6ab5ae967bcd8b10b23f68f42493829',
apiSecret: '861b0dff9a8a574bec747f9dab8b82bf',
username: username,
passwordHash: password);
_scrobblenaut = Scrobblenaut(lastFM: lastFM);
} catch (e) {
print(e);
}
break;
case 'disableLastFM':
_scrobblenaut = null;
break;
}
return true;
}
@override
Future onTaskRemoved() => stop();
@override
Future onNotificationDeleted() => stop();
@override
Future<void> stop() async {
await _saveQueue();
_player.stop();
_eventSub?.cancel();
_audioSessionSub?.cancel();
_visualizerSubscription?.cancel();
await super.stop();
}
//Export queue to -JSON- hive box
Future<void> _saveQueue() async {
if (_queueIndex == 0 && queue.value.isEmpty) return;
await _box.clear();
await _box.putAll(<String, dynamic>{
'index': _queueIndex,
'queue': queue.value,
'position': _player.position,
'queueSource': queueSource,
'repeatMode': _repeatMode,
});
}
//Restore queue & playback info from path
Future<void> _loadQueueFile() async {
if (_box.isEmpty) {
await _loadQueue();
return;
}
final _queue = ((await _box.get('queue')) as List?)?.cast<MediaItem>();
_queueIndex = await _box.get('index', defaultValue: 0);
_lastPosition = await _box.get('position', defaultValue: Duration.zero);
queueSource =
await _box.get('queueSource', defaultValue: const QueueSource());
_repeatMode =
await _box.get('repeatMode', defaultValue: AudioServiceRepeatMode.none);
//Restore queue
if (_queue != null) {
queue.add(_queue);
await _loadQueue();
mediaItem.add(currentMediaItem);
} else {
await _loadQueue();
}
//Send restored queue source to ui
customEvent.add({
'action': 'onRestore',
'queueSource': queueSource!,
'repeatMode': _repeatMode
});
}
@override
Future<void> insertQueueItem(int index, MediaItem mi) async {
//-1 == play next
if (index == -1) index = _queueIndex + 1;
queue.add(List.from(queue.value)..insert(index, mi));
AudioSource? _newSource = await _mediaItemToAudioSource(mi);
await _audioSource.insert(index, _newSource);
_saveQueue();
}
//Add at end of queue
@override
Future<void> addQueueItem(MediaItem mediaItem,
{bool shouldSaveQueue = true}) async {
if (queue.value.indexWhere((m) => m.id == mediaItem.id) != -1) return;
queue.add(List.from(queue.value)..add(mediaItem));
AudioSource _newSource = await _mediaItemToAudioSource(mediaItem);
await _audioSource.add(_newSource);
if (shouldSaveQueue) _saveQueue();
}
@override
Future<void> addQueueItems(List<MediaItem> mediaItems) async {
for (final mediaItem in mediaItems) {
await addQueueItem(mediaItem, shouldSaveQueue: false);
}
_saveQueue();
}
@override
Future<void> playFromMediaId(String mediaId,
[Map<String, dynamic>? args]) async {
//Android auto load tracks
if (mediaId.startsWith(AndroidAuto.prefix)) {
customEvent.add({
'action': 'tracksAndroidAuto',
'id': mediaId.replaceFirst(AndroidAuto.prefix, '')
});
return;
}
//Does the same thing
await this
.skipToQueueItem(queue.value.indexWhere((item) => item.id == mediaId));
}
@override
Future<MediaItem?> getMediaItem(String mediaId) async =>
queue.value.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId);
@override
Future<void> playMediaItem(MediaItem mediaItem) =>
playFromMediaId(mediaItem.id);
@override
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) =>
_player.setLoopMode(repeatMode.toLoopMode());
@override
Future<void> setShuffleMode(AudioServiceShuffleMode shuffleMode) async {
switch (shuffleMode) {
case AudioServiceShuffleMode.none:
queue.add(_originalQueue!);
_originalQueue = null;
break;
case AudioServiceShuffleMode.group:
case AudioServiceShuffleMode.all:
_originalQueue = List.from(queue.value, growable: false);
queue.add(queue.value..shuffle());
break;
}
_queueIndex = 0;
await _player.stop();
await _loadQueue();
await _player.play();
}
//Called when queue ends to load more tracks
Future<void> _onQueueEnd() async {
print('ON QUEUE END CALLED!');
print('getting track list ${queueSource?.source}');
if (queueSource == null) return;
List<Track>? tracks;
switch (queueSource!.source) {
//Flow
case 'flow':
tracks = await _deezerAPI.flow(queueSource!.id);
break;
//SmartRadio/Artist radio
case 'smartradio':
tracks = await _deezerAPI.smartRadio(queueSource!.id!);
break;
//Library shuffle
case 'libraryshuffle':
tracks = await _deezerAPI.libraryShuffle(
start: audioHandler.queue.value.length);
break;
case 'mix':
tracks = await _deezerAPI.playMix(queueSource!.id);
// Deduplicate tracks with the same id
List<String> queueIds = queue.value.map((e) => e.id).toList();
tracks?.removeWhere((track) => queueIds.contains(track.id));
break;
case 'smarttracklist':
tracks = (await _deezerAPI.smartTrackList(queueSource!.id!)).tracks;
default:
return;
// print(queueSource.toJson());
}
if (tracks == null) {
throw Exception(
'Failed to fetch more queue songs (queueSource: ${queueSource!.toJson()})');
}
final mi = await Future.wait(
tracks.map<Future<MediaItem>>((t) => t.toMediaItem()));
await addQueueItems(mi);
}
}
//Seeker from audio_service example (why reinvent the wheel?)
//While holding seek button, will continuously seek
class Seeker {
final AudioPlayer? player;
final Duration positionInterval;
final Duration stepInterval;
final MediaItem mediaItem;
bool _running = false;
Seeker(this.player, this.positionInterval, this.stepInterval, this.mediaItem);
Future start() async {
_running = true;
while (_running) {
Duration newPosition = player!.position + positionInterval;
if (newPosition < Duration.zero) newPosition = Duration.zero;
if (newPosition > mediaItem.duration!) newPosition = mediaItem.duration!;
player!.seek(newPosition);
await Future.delayed(stepInterval);
}
}
void stop() {
_running = false;
}
}