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(); ValueStream 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 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 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 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 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 _loadQueuePlay(List 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 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 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 playShowEpisode(Show show, List episodes, {int index = 0}) async { QueueSource queueSource = QueueSource(id: show.id, text: show.name, source: 'show'); //Generate media items List queue = episodes.map((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 tracks, String? trackId, QueueSource queueSource) async { final queue = await Future.wait(tracks .map>((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? _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>? _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 _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?); } //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 fastForward() => _seekRelative(AudioService.config.fastForwardInterval); @override Future rewind() => _seekRelative(-AudioService.config.rewindInterval); @override Future seekForward(bool begin) async => _seekContinuously(begin, 1); @override Future seekBackward(bool begin) async => _seekContinuously(begin, -1); //Remove item from queue @override Future removeQueueItem(MediaItem mediaItem) async { int index = queue.value.indexWhere((m) => m.id == mediaItem.id); removeQueueItemAt(index); } @override Future removeQueueItemAt(int index) async { if (index <= _queueIndex) { _queueIndex--; } await _audioSource.removeAt(index); queue.add(queue.value..removeAt(index)); } @override Future skipToNext() async { _lastPosition = null; if (_queueIndex == queue.value.length - 1) return; //Update buffering state _queueIndex++; await _player.seekToNext(); _broadcastState(); } @override Future skipToPrevious() async { if (_queueIndex == 0) return; //Update buffering state //_skipState = AudioProcessingState.skippingToPrevious; //Normal skip to previous _queueIndex--; await _player.seekToPrevious(); //_skipState = null; } @override Future> getChildren(String parentMediaId, [Map? options]) async { customEvent.add({'action': 'screenAndroidAuto', 'id': parentMediaId}); //Wait for data from main thread _androidAutoCallback = Completer>(); 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.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 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 _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? 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?)); 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 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 stop() async { await _saveQueue(); _player.stop(); _eventSub?.cancel(); _audioSessionSub?.cancel(); _visualizerSubscription?.cancel(); await super.stop(); } //Export queue to -JSON- hive box Future _saveQueue() async { if (_queueIndex == 0 && queue.value.isEmpty) return; await _box.clear(); await _box.putAll({ 'index': _queueIndex, 'queue': queue.value, 'position': _player.position, 'queueSource': queueSource, 'repeatMode': _repeatMode, }); } //Restore queue & playback info from path Future _loadQueueFile() async { if (_box.isEmpty) { await _loadQueue(); return; } final _queue = ((await _box.get('queue')) as List?)?.cast(); _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 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 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 addQueueItems(List mediaItems) async { for (final mediaItem in mediaItems) { await addQueueItem(mediaItem, shouldSaveQueue: false); } _saveQueue(); } @override Future playFromMediaId(String mediaId, [Map? 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 getMediaItem(String mediaId) async => queue.value.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId); @override Future playMediaItem(MediaItem mediaItem) => playFromMediaId(mediaItem.id); @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) => _player.setLoopMode(repeatMode.toLoopMode()); @override Future 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 _onQueueEnd() async { print('ON QUEUE END CALLED!'); print('getting track list ${queueSource?.source}'); if (queueSource == null) return; List? 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 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>((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; } }