import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:equalizer/equalizer.dart'; import 'package:flutter/foundation.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; 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; final _bufferPositionSubject = BehaviorSubject(); ValueStream get bufferPosition => _bufferPositionSubject.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 { final initArgs = AudioPlayerTaskInitArguments.from( settings: settings, deezerAPI: deezerAPI); // initialize our audiohandler instance audioHandler = await AudioService.init( builder: () => AudioPlayerTask(initArgs), 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(); break; case 'onRestore': //Load queueSource from isolate queueSource = event['queueSource'] as QueueSource; repeatType = event['repeatMode'] as AudioServiceRepeatMode; _queueIndex = getQueueIndex(); 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; case 'bufferPosition': _bufferPositionSubject.add(event['data'] as Duration); 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.isNotEmpty && cache.history.last.id == mediaItem.id) { return; } cache.history.add(Track.fromMediaItem(mediaItem)); cache.save(); }); //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 { _shuffleEnabled = !_shuffleEnabled; await audioHandler.setShuffleMode(_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 { 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 AudioPlayerTaskInitArguments { final bool ignoreInterruptions; final bool seekAsSkip; final DeezerAPI deezerAPI; final bool logListen; final String? lastFMUsername; final String? lastFMPassword; AudioPlayerTaskInitArguments({ required this.deezerAPI, required this.ignoreInterruptions, required this.seekAsSkip, required this.logListen, required this.lastFMUsername, required this.lastFMPassword, }); static AudioPlayerTaskInitArguments from( {required Settings settings, required DeezerAPI deezerAPI}) { return AudioPlayerTaskInitArguments( deezerAPI: deezerAPI, logListen: settings.logListen, ignoreInterruptions: settings.ignoreInterruptions, seekAsSkip: settings.seekAsSkip, lastFMUsername: settings.lastFMUsername, lastFMPassword: settings.lastFMPassword); } static Future loadSettings() async { final settings = await Settings.load(); final deezerAPI = DeezerAPI(arl: settings.arl); await deezerAPI.authorize(); return from(settings: settings, deezerAPI: deezerAPI); } } class AudioPlayerTask extends BaseAudioHandler { final _logger = Logger('AudioPlayerTask'); late AudioPlayer _player; late ConcatenatingAudioSource _audioSource; late DeezerAPI _deezerAPI; late bool _seekAsSkip; /// Original queue from shuffle List? _originalQueue; /// Current index in queue int _queueIndex = 0; /// Used to assign unique ids to queue items. /// This shall not be decremented, and shall only be reset when the queue is being recreated. /// /// USE CASE: assigning keys to queue items (for example in [QueueScreen]) int _queueAutoIncrement = 0; //Stream subscriptions StreamSubscription? _eventSubscription; StreamSubscription? _bufferPositionSubscription; StreamSubscription? _audioSessionSubscription; StreamSubscription? _visualizerSubscription; StreamSubscription? _connectivitySubscription; /// Android Auto helper class for navigation late final AndroidAuto _androidAuto; //Loaded from file/frontendjust AudioQuality mobileQuality = AudioQuality.MP3_128; AudioQuality wifiQuality = AudioQuality.MP3_128; AudioQuality _currentQuality = AudioQuality.MP3_128; /// Current queueSource (=> where playback has begun from) QueueSource? queueSource; /// Last playback position (used for restoring position when player died) Duration? _lastPosition; AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none; /// LastFM API Scrobblenaut? _scrobblenaut; /// Last logged track id (to avoid duplicates) String? _loggedTrackId; // deezer track logging /// Whether we should send log requests to deezer late bool _shouldLogTracks; /// Amount of times the song's been paused int _amountPaused = 0; /// Amount of times the song's been seeked int _amountSeeked = 0; /// When playback begun int? _timestamp; MediaItem get currentMediaItem => queue.value[_queueIndex]; bool get currentMediaItemIsShow => queue.value.elementAtOrNull(_queueIndex)?.extras?['show'] != null; /// Hive playback box (LazyBox = don't keep stuff in RAM) late final LazyBox _box; AudioPlayerTask([AudioPlayerTaskInitArguments? initArgs]) { if (initArgs == null) { unawaited(AudioPlayerTaskInitArguments.loadSettings().then(_init)); return; } unawaited(_init(initArgs)); } Future _init(AudioPlayerTaskInitArguments initArgs) async { _deezerAPI = initArgs.deezerAPI; _androidAuto = AndroidAuto(deezerAPI: _deezerAPI); _shouldLogTracks = initArgs.logListen; _seekAsSkip = initArgs.seekAsSkip; final session = await AudioSession.instance; session.configure(const AudioSessionConfiguration.music()); _box = await Hive.openLazyBox('playback', path: (await getTemporaryDirectory()).path); _player = AudioPlayer( handleInterruptions: !initArgs.ignoreInterruptions, androidApplyAudioAttributes: true, handleAudioSessionActivation: true, ); if (initArgs.ignoreInterruptions) { session.interruptionEventStream.listen((_) {}); session.becomingNoisyEventStream.listen((_) {}); } //Update track index _player.currentIndexStream.listen((index) { _timestamp = DateTime.now().millisecondsSinceEpoch; 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 _eventSubscription = _player.playbackEventStream.listen((event) { //Update _broadcastState(); }, onError: (Object e, StackTrace st) { _logger.severe('A stream error occurred: $e'); }); _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!.toJson()}); } break; default: break; } }); _bufferPositionSubscription = _player.bufferedPositionStream.listen((bufferPosition) { customEvent.add({'action': 'bufferPosition', 'data': bufferPosition}); }); //Audio session _audioSessionSubscription = _player.androidAudioSessionIdStream.listen((event) { customEvent.add({'action': 'audioSession', 'id': event}); }); //Load queue // queue.add(_queue); // Determine audio quality to use try { await Connectivity().checkConnectivity().then(_determineAudioQuality); // listen for connectivity changes _connectivitySubscription = Connectivity().onConnectivityChanged.listen(_determineAudioQuality); } catch (e) { _logger.warning( 'Couldn\'t determine connection! Falling back to other (which may use wifi quality)'); // on error, return dummy value -- error can happen on linux if not using NetworkManager, for example _determineAudioQuality(ConnectivityResult.other); } await _loadQueueFile(); if (initArgs.lastFMUsername != null && initArgs.lastFMPassword != null) { await _authorizeLastFM( initArgs.lastFMUsername!, initArgs.lastFMPassword!); } customEvent.add({'action': 'onLoad'}); } void _determineAudioQuality(ConnectivityResult result) { switch (result) { case ConnectivityResult.mobile: case ConnectivityResult.bluetooth: _currentQuality = mobileQuality; case ConnectivityResult.other: _currentQuality = Platform.isLinux || Platform.isLinux || Platform.isMacOS ? wifiQuality : mobileQuality; default: _currentQuality = wifiQuality; } } @override Future skipToQueueItem(int index) async { _lastPosition = null; unawaited(_logListenedTrack(currentMediaItem)); //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) { _player.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, duration: currentMediaItem.duration, ); } } @override Future pause() { _amountPaused++; return _player.pause(); } @override Future seek(Duration? position) { _amountSeeked++; return _player.seek(position); } @override Future fastForward() { print('fast forward called'); if (currentMediaItemIsShow) { return _seekRelative(const Duration(seconds: 30)); } if (_seekAsSkip) return skipToNext(); return Future.value(); } @override Future rewind() { print('rewind called'); if (currentMediaItemIsShow) { return _seekRelative(-const Duration(seconds: 30)); } if (_seekAsSkip) return skipToPrevious(); return Future.value(); } // unused. @override Future seekForward(bool begin) => Future.value(); @override Future seekBackward(bool begin) async { // (un)favourite action if (cache.libraryTracks.contains(currentMediaItem.id)) { cache.libraryTracks.remove(currentMediaItem.id); _broadcastState(); return _deezerAPI.removeFavorite(currentMediaItem.id); } cache.libraryTracks.add(currentMediaItem.id); _broadcastState(); return _deezerAPI.addFavoriteTrack(currentMediaItem.id); } //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 unawaited(_logListenedTrack(currentMediaItem)); _queueIndex++; await _player.seekToNext(); _broadcastState(); } @override Future skipToPrevious() async { if (_queueIndex == 0) return; //Update buffering state //_skipState = AudioProcessingState.skippingToPrevious; //Normal skip to previous unawaited(_logListenedTrack(currentMediaItem)); _queueIndex--; await _player.seekToPrevious(); //_skipState = null; } Future _logListenedTrack(MediaItem mediaItem) async { if (!_shouldLogTracks) return; //Log to Deezer _deezerAPI.logListen( mediaItem.id, seek: _amountSeeked, pause: _amountPaused, sync: _amountSeeked == 0 && _amountPaused == 0 ? 1 : 0, timestamp: _timestamp, ); } @override Future> getChildren(String parentMediaId, [Map? options]) async { return await _androidAuto.getScreen(parentMediaId); } //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); } MediaControl favoriteControl() { if (cache.libraryTracks.contains(currentMediaItem.id)) { return const MediaControl( androidIcon: 'drawable/ic_heart', label: 'unfavorite', action: MediaAction.seekBackward); } return const MediaControl( androidIcon: 'drawable/ic_heart_outline', label: 'favourite', action: MediaAction .seekBackward, // this acts as favourite/unfavourite as it's not already used. ); } //Update state on all clients void _broadcastState() { playbackState.add(PlaybackState( controls: !currentMediaItemIsShow ? [ if (queue.value.isNotEmpty) favoriteControl(), /*if (_queueIndex != 0)*/ MediaControl.skipToPrevious, _player.playing ? MediaControl.pause : MediaControl.play, /*if (_queueIndex != _queue!.length - 1)*/ MediaControl .skipToNext, //Stop -- USELESS. // MediaControl( // androidIcon: 'drawable/ic_action_stop', // label: 'stop', // action: MediaAction.stop), // i mean, the user can just swipe the notification away to stop ] : [ const MediaControl( androidIcon: 'drawable/ic_replay_30', label: 'replay 30', action: MediaAction.rewind, ), // acts as prev-30 _player.playing ? MediaControl.pause : MediaControl.play, const MediaControl( androidIcon: 'drawable/ic_forward_30', label: 'forward 30', action: MediaAction.fastForward, ), // next-30 ], systemActions: !currentMediaItemIsShow ? const { MediaAction.seek, MediaAction.seekBackward, MediaAction.skipToNext, MediaAction.skipToPrevious, 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 // ignore: avoid_renaming_method_parameters Future updateQueue(List q) async { _lastPosition = null; //just_audio _originalQueue = null; _player.stop(); // assign unique ids _queueAutoIncrement = 0; for (var e in q) { e.extras!['id'] = _queueAutoIncrement++; } queue.add(q); //Load await _loadQueue(); //await _player.seek(Duration.zero, index: 0); -- will be done in frontend } //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((e) => _mediaItemToAudioSource(e))); _audioSource = ConcatenatingAudioSource( children: sources, // load stuff as late as possible => less data used useLazyPreparation: true, ); //Load in just_audio try { await _player.setAudioSource(_audioSource, initialIndex: _queueIndex, initialPosition: Duration.zero, preload: kIsWeb || defaultTargetPlatform != TargetPlatform.linux); } 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']); 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( getQuality: () => _currentQuality, 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? extras]) async { switch (name) { case 'updateQuality': //Pass wifi & mobile quality by custom action //Isolate can't access globals wifiQuality = extras!['wifiQuality'] as AudioQuality; mobileQuality = extras['mobileQuality'] as AudioQuality; break; //Update queue source case 'queueSource': queueSource = QueueSource.fromJson(extras!); break; //Looping // case 'repeatType': // _loopMode = LoopMode.values[args!['type']]; // _player.setLoopMode(_loopMode); // break; //Save queue case 'saveQueue': await _saveQueue(); break; //Reorder tracks, args = [old, new] case 'reorder': final oldIndex = extras!['oldIndex']! as int; final newIndex = extras['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 = extras!['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 = extras!['username']! as String; final password = extras['password']! as String; await _authorizeLastFM(username, password); break; case 'disableLastFM': _scrobblenaut = null; break; } return true; } @override Future onTaskRemoved() => stop(); @override Future onNotificationDeleted() => stop(); @override Future stop() async { await _saveQueue(); _player.stop(); _eventSubscription?.cancel(); _audioSessionSubscription?.cancel(); _visualizerSubscription?.cancel(); _bufferPositionSubscription?.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 q = ((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 (q != null) { _queueAutoIncrement = q.length; queue.add(q); 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 mediaItem) async { //-1 == play next if (index == -1) index = _queueIndex + 1; queue.add(List.from(queue.value) ..insert(index, mediaItem..extras!['id'] = _queueAutoIncrement++)); AudioSource? newSource = await _mediaItemToAudioSource(mediaItem); 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..extras!['id'] = _queueAutoIncrement++)); 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? extras]) async { //Android auto load tracks if (mediaId.startsWith(AndroidAuto.prefix)) { await _androidAuto.playItem(mediaId.substring(AndroidAuto.prefix.length)); return; } //Does the same thing await 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 { 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); } Future _authorizeLastFM(String username, String password) async { _scrobblenaut = Scrobblenaut( lastFM: await LastFM.authenticateWithPasswordHash( apiKey: 'b6ab5ae967bcd8b10b23f68f42493829', apiSecret: '861b0dff9a8a574bec747f9dab8b82bf', username: username, passwordHash: password)); } } //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; } }