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/ui/android_auto.dart'; import 'package:just_audio/just_audio.dart'; import 'package:connectivity/connectivity.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 '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; //Visualizer StreamController _visualizerController = StreamController.broadcast(); Stream get visualizerStream => _visualizerController.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(), config: AudioServiceConfig( notificationColor: settings.primaryColor, androidStopForegroundOnPause: false, androidNotificationOngoing: false, androidNotificationClickStartsActivity: true, androidNotificationChannelDescription: 'Freezer', androidNotificationChannelName: 'Freezer', androidNotificationIcon: 'drawable/ic_logo', preloadArtwork: false, ), ); } Future start() async { audioHandler.customAction( 'start', {'ignoreInterruptions': settings.ignoreInterruptions}); //Subscribe to custom events _customEventSubscription = audioHandler.customEvent.listen((event) async { if (!(event is Map)) return; 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 this.queueSource = QueueSource.fromJson(event['queueSource']); repeatType = AudioServiceRepeatMode.values[event['loopMode']]; _queueIndex = getQueueIndex(); break; case 'queueEnd': //If last song is played, load more queue this.queueSource = QueueSource.fromJson(event['queueSource']); // onQueueEnd(); break; case 'screenAndroidAuto': AndroidAuto androidAuto = AndroidAuto(); List data = await androidAuto.getScreen(event['id']); await audioHandler .customAction('screenAndroidAuto', {'val': jsonEncode(data)}); break; case 'tracksAndroidAuto': AndroidAuto androidAuto = AndroidAuto(); 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; } }); _mediaItemSubscription = audioHandler.mediaItem.listen((mediaItem) async { if (mediaItem == null) return; final queue = audioHandler.queue.value; final nextIndex = (_queueIndex + 1) % queue.length; print('animating $nextIndex'); _queueIndex = getQueueIndex(); //Load more flow if last song (not using .last since it iterates through previous elements first) if (mediaItem.id == queue[queue.length - 1].id) await onQueueEnd(); //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': 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(); } //Called when queue ends to load more tracks Future onQueueEnd() async { if (queueSource == null) return; List? tracks; switch (queueSource!.source) { //Flow case 'flow': tracks = await deezerAPI.flow(); break; //SmartRadio/Artist radio case 'smartradio': tracks = await (deezerAPI.smartRadio(queueSource!.id!) as FutureOr>); 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 = audioHandler.queue.value.map((e) => e.id).toList(); tracks?.removeWhere((track) => queueIds.contains(track.id)); break; default: return; // print(queueSource.toJson()); } if (tracks == null) { throw Exception( 'Failed to fetch more queue songs (queueSource: ${queueSource!.toJson()})'); } List mi = tracks.map((t) => t.toMediaItem()).toList(); await audioHandler.addQueueItems(mi); // AudioService.skipToNext(); } //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 { 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 { List queue = 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!.length == 0) { 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(); } else { stl = await deezerAPI.smartTrackList(stl.id); } } QueueSource queueSource = QueueSource( id: 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) async { await 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; //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/frontend int? mobileQuality; int? wifiQuality; QueueSource? queueSource; Duration? _lastPosition; AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none; Completer>? _androidAutoCallback; Scrobblenaut? _scrobblenaut; bool _scrobblenautReady = false; // Last logged track id String? _loggedTrackId; MediaItem get currentMediaItem => queue.value[_queueIndex]; Future onStart(Map? params) async { final session = await AudioSession.instance; session.configure(AudioSessionConfiguration.music()); if (params?['ignoreInterruptions'] == true) { _player = AudioPlayer(handleInterruptions: false); session.interruptionEventStream.listen((_) {}); session.becomingNoisyEventStream.listen((_) {}); } else _player = AudioPlayer(); //Update track index _player.currentIndexStream.listen((index) { if (index != null) { _queueIndex = index; mediaItem.add(currentMediaItem); } }); //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 (_scrobblenautReady && 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: [ /*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 ], systemActions: const { MediaAction.seek, MediaAction.seekForward, MediaAction.seekBackward, MediaAction.stop }, processingState: _getProcessingState(), 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 _getProcessingState() { return const { ProcessingState.idle: AudioProcessingState.idle, ProcessingState.loading: AudioProcessingState.loading, ProcessingState.buffering: AudioProcessingState.buffering, ProcessingState.ready: AudioProcessingState.ready, ProcessingState.completed: AudioProcessingState.completed }[_player.processingState] ?? AudioProcessingState.idle; } //Replace current queue @override Future updateQueue(List q) async { _lastPosition = null; //just_audio _originalQueue = null; _player.stop(); if (_isInitialized) _audioSource.clear(); //Filter duplicate IDs List newQueue = q.toSet().toList(); // broadcast to ui queue.add(newQueue); //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 int? qi = _queueIndex; List sources = []; for (int i = 0; i < queue.value.length; i++) { AudioSource s = await _mediaItemToAudioSource(queue.value[i]); sources.add(s); } _audioSource = ConcatenatingAudioSource(children: sources); //Load in just_audio try { await _player.setAudioSource(_audioSource, initialIndex: qi, initialPosition: Duration.zero); } catch (e) { //Error loading tracks } _queueIndex = qi; mediaItem.add(currentMediaItem); } Future _mediaItemToAudioSource(MediaItem mi) async { String? url = await _getTrackUrl(mi); final uri = Uri.parse(url); if (uri.isScheme('http') || uri.isScheme('https')) return ProgressiveAudioSource(uri); return AudioSource.uri(uri, tag: mi.id); } Future _getTrackUrl(MediaItem mediaItem, {int? quality}) async { //Check if offline String _offlinePath = p.join((await getExternalStorageDirectory())!.path, 'offline/'); File f = File(p.join(_offlinePath, mediaItem.id)); if (await f.exists()) { //return f.path; //Stream server URL return 'http://localhost:36958/?id=${mediaItem.id}'; } //Show episode direct link if (mediaItem.extras!['showUrl'] != null) return mediaItem.extras!['showUrl']; //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(); 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}'; String url = 'http://localhost:36958/?q=$quality&mv=${playbackDetails![1]}&md5origin=${playbackDetails[0]}&id=${mediaItem.id}'; return url; } //Custom actions @override Future customAction(String name, [Map? args]) async { switch (name) { case 'start': onStart(args); break; case 'updateQuality': //Pass wifi & mobile quality by custom action //Isolate can't access globals this.wifiQuality = args!['wifiQuality']; this.mobileQuality = args['mobileQuality']; break; //Update queue source case 'queueSource': this.queueSource = QueueSource.fromJson(args!); break; //Looping // case 'repeatType': // _loopMode = LoopMode.values[args!['type']]; // _player.setLoopMode(_loopMode); // break; //Save queue case 'saveQueue': await this._saveQueue(); break; //Load queue after some initialization in frontend case 'load': await this._loadQueueFile(); break; //Android audio callback case 'screenAndroidAuto': if (_androidAutoCallback != null) _androidAutoCallback!.complete( (args!['value'] as List>?) ?.map(mediaItemFromJson) .toList()); break; //Reorder tracks, args = [old, new] case 'reorder': final oldIndex = args!['oldIndex']! as int; final newIndex = args['newIndex']! as int; await _audioSource.move(oldIndex, newIndex); queue.add(queue.value..reorder(oldIndex, newIndex)); _broadcastState(); break; //Set index without affecting playback for loading case 'setIndex': // editor's note: i really don't get what this is for this._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); _scrobblenautReady = true; } catch (e) { print(e); } break; case 'disableLastFM': _scrobblenaut = null; _scrobblenautReady = false; 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(); } //Get queue save file path Future _getQueuePath() async { Directory dir = await getApplicationDocumentsDirectory(); return p.join(dir.path, 'playback.json'); } //Export queue to JSON Future _saveQueue() async { if (_queueIndex == 0 && queue.value.length == 0) return; String path = await _getQueuePath(); File f = File(path); //Create if doesn't exist if (!await File(path).exists()) { f = await f.create(); } Map data = { 'index': _queueIndex, 'queue': queue.value.map>(mediaItemToJson).toList(), 'position': _player.position.inMilliseconds, 'queueSource': (queueSource ?? QueueSource()).toJson(), 'loopMode': _repeatMode.index, }; await f.writeAsString(jsonEncode(data)); } //Restore queue & playback info from path Future _loadQueueFile() async { File f = File(await _getQueuePath()); if (await f.exists()) { Map json = jsonDecode(await f.readAsString()); List? _queue = (json['queue'] as List?) ?.cast() .map( (json) => mediaItemFromJson(json.cast())) .toList(); _queueIndex = json['index'] ?? 0; _lastPosition = Duration(milliseconds: json['position'] ?? 0); queueSource = QueueSource.fromJson(json['queueSource'] ?? {}); _repeatMode = AudioServiceRepeatMode.values[(json['loopMode'] ?? 0)]; //Restore queue if (_queue != null) { queue.add(_queue); await _loadQueue(); mediaItem.add(currentMediaItem); } //Send restored queue source to ui customEvent.add({ 'action': 'onRestore', 'queueSource': (queueSource ?? QueueSource()).toJson(), 'loopMode': _repeatMode.index }); } return true; } @override Future insertQueueItem(int index, MediaItem mi) async { //-1 == play next if (index == -1) index = _queueIndex + 1; queue.add(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(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(); } } //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; } }