import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:android_id/android_id.dart'; import 'package:audio_service/audio_service.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:finamp/services/offline_listen_helper.dart'; import 'package:flutter/foundation.dart'; import 'package:get_it/get_it.dart'; import 'package:just_audio/just_audio.dart'; import 'package:logging/logging.dart'; import '../models/finamp_models.dart'; import '../models/jellyfin_models.dart'; import 'finamp_settings_helper.dart'; import 'finamp_user_helper.dart'; import 'jellyfin_api_helper.dart'; // Largely copied from just_audio's DefaultShuffleOrder, but with a mildly // stupid hack to insert() to make Play Next work class FinampShuffleOrder extends ShuffleOrder { final Random _random; @override final indices = []; FinampShuffleOrder({Random? random}) : _random = random ?? Random(); @override void shuffle({int? initialIndex}) { assert(initialIndex == null || indices.contains(initialIndex)); if (indices.length <= 1) return; indices.shuffle(_random); if (initialIndex == null) return; const initialPos = 0; final swapPos = indices.indexOf(initialIndex); // Swap the indices at initialPos and swapPos. final swapIndex = indices[initialPos]; indices[initialPos] = initialIndex; indices[swapPos] = swapIndex; } @override void insert(int index, int count) { // Offset indices after insertion point. for (var i = 0; i < indices.length; i++) { if (indices[i] >= index) { indices[i] += count; } } final newIndices = List.generate(count, (i) => index + i); // This is the only modification from DefaultShuffleOrder: Only shuffle // inserted indices amongst themselves, but keep them contiguous newIndices.shuffle(_random); indices.insertAll(index, newIndices); } @override void removeRange(int start, int end) { final count = end - start; // Remove old indices. final oldIndices = List.generate(count, (i) => start + i).toSet(); indices.removeWhere(oldIndices.contains); // Offset indices after deletion point. for (var i = 0; i < indices.length; i++) { if (indices[i] >= end) { indices[i] -= count; } } } @override void clear() { indices.clear(); } } /// This provider handles the currently playing music so that multiple widgets /// can control music. class MusicPlayerBackgroundTask extends BaseAudioHandler { final _player = AudioPlayer( audioLoadConfiguration: AudioLoadConfiguration( androidLoadControl: AndroidLoadControl( minBufferDuration: FinampSettingsHelper.finampSettings.bufferDuration, maxBufferDuration: FinampSettingsHelper.finampSettings.bufferDuration, prioritizeTimeOverSizeThresholds: true, ), darwinLoadControl: DarwinLoadControl( preferredForwardBufferDuration: FinampSettingsHelper.finampSettings.bufferDuration, )), ); ConcatenatingAudioSource _queueAudioSource = ConcatenatingAudioSource( children: [], shuffleOrder: FinampShuffleOrder(), ); final _audioServiceBackgroundTaskLogger = Logger("MusicPlayerBackgroundTask"); final _jellyfinApiHelper = GetIt.instance(); final _offlineListenLogHelper = GetIt.instance(); final _finampUserHelper = GetIt.instance(); /// Set when shuffle mode is changed. If true, [onUpdateQueue] will create a /// shuffled [ConcatenatingAudioSource]. bool shuffleNextQueue = false; /// Set when creating a new queue. Will be used to set the first index in a /// new queue. int? nextInitialIndex; /// Set to true when we're stopping the audio service. Used to avoid playback /// progress reporting. bool _isStopping = false; /// Holds the current sleep timer, if any. This is a ValueNotifier so that /// widgets like SleepTimerButton can update when the sleep timer is/isn't /// null. bool _sleepTimerIsSet = false; Duration _sleepTimerDuration = Duration.zero; final ValueNotifier _sleepTimer = ValueNotifier(null); List? get shuffleIndices => _player.shuffleIndices; ValueListenable get sleepTimer => _sleepTimer; MusicPlayerBackgroundTask() { _audioServiceBackgroundTaskLogger.info("Starting audio service"); // Propagate all events from the audio player to AudioService clients. _player.playbackEventStream.listen((event) async { final prevState = playbackState.valueOrNull; final prevIndex = prevState?.queueIndex; final prevItem = mediaItem.valueOrNull; final currentState = _transformEvent(event); final currentIndex = currentState.queueIndex; playbackState.add(currentState); if (currentIndex != null) { final currentItem = _getQueueItem(currentIndex); // Differences in queue index or item id are considered track changes if (currentIndex != prevIndex || currentItem.id != prevItem?.id) { mediaItem.add(currentItem); onTrackChanged(currentItem, currentState, prevItem, prevState); } } if (playbackState.valueOrNull != null && playbackState.valueOrNull?.processingState != AudioProcessingState.idle && playbackState.valueOrNull?.processingState != AudioProcessingState.completed && !FinampSettingsHelper.finampSettings.isOffline && !_isStopping) { await _updatePlaybackProgress(); } }); // Special processing for state transitions. _player.processingStateStream.listen((event) { if (event == ProcessingState.completed) { stop(); } }); // PlaybackEvent doesn't include shuffle/loops so we listen for changes here _player.shuffleModeEnabledStream.listen( (_) => playbackState.add(_transformEvent(_player.playbackEvent))); _player.loopModeStream.listen( (_) => playbackState.add(_transformEvent(_player.playbackEvent))); } @override Future play() { // If a sleep timer has been set and the timer went off // causing play to pause, if the user starts to play // audio again, and the sleep timer hasn't been explicitly // turned off, then reset the sleep timer. // This is useful if the sleep timer pauses play too early // and the user wants to continue listening if (_sleepTimerIsSet && _sleepTimer.value == null) { // restart the sleep timer for another period setSleepTimer(_sleepTimerDuration); } return _player.play(); } @override Future pause() => _player.pause(); @override Future stop() async { try { _audioServiceBackgroundTaskLogger.info("Stopping audio service"); _isStopping = true; // Tell Jellyfin we're no longer playing audio if we're online if (!FinampSettingsHelper.finampSettings.isOffline) { final playbackInfo = generateCurrentPlaybackProgressInfo(); if (playbackInfo != null) { await _jellyfinApiHelper.stopPlaybackProgress(playbackInfo); } } else { final currentIndex = _player.currentIndex; if (_queueAudioSource.length != 0 && currentIndex != null) { final item = _getQueueItem(currentIndex); _offlineListenLogHelper.logOfflineListen(item); } } // Stop playing audio. await _player.stop(); // Seek to the start of the first item in the queue await _player.seek(Duration.zero, index: 0); _sleepTimerIsSet = false; _sleepTimerDuration = Duration.zero; _sleepTimer.value?.cancel(); _sleepTimer.value = null; await super.stop(); // await _player.dispose(); // await _eventSubscription?.cancel(); // // It is important to wait for this state to be broadcast before we shut // // down the task. If we don't, the background task will be destroyed before // // the message gets sent to the UI. // await _broadcastState(); // // Shut down this background task // await super.stop(); _isStopping = false; } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } @override @Deprecated("Use addQueueItems instead") Future addQueueItem(MediaItem mediaItem) async { addQueueItems([mediaItem]); } @override Future addQueueItems(List mediaItems) async { try { final sources = await Future.wait(mediaItems.map((i) => _mediaItemToAudioSource(i))); await _queueAudioSource.addAll(sources); queue.add(_queueFromSource()); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } Future insertQueueItemsNext(List mediaItems) async { try { var idx = _player.currentIndex; if (idx != null) { if (_player.shuffleModeEnabled) { var next = _player.shuffleIndices?.indexOf(idx); idx = next == -1 || next == null ? null : next + 1; } else { ++idx; } } idx ??= 0; final sources = await Future.wait(mediaItems.map((i) => _mediaItemToAudioSource(i))); await _queueAudioSource.insertAll(idx, sources); queue.add(_queueFromSource()); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } @override Future updateQueue(List newQueue) async { try { // Convert the MediaItems to AudioSources List audioSources = []; for (final mediaItem in newQueue) { audioSources.add(await _mediaItemToAudioSource(mediaItem)); } // Create a new ConcatenatingAudioSource with the new queue. _queueAudioSource = ConcatenatingAudioSource( children: audioSources, shuffleOrder: FinampShuffleOrder(), ); try { await _player.setAudioSource( _queueAudioSource, initialIndex: nextInitialIndex, ); } on PlayerException catch (e) { _audioServiceBackgroundTaskLogger .severe("Player error code ${e.code}: ${e.message}"); } on PlayerInterruptedException catch (e) { _audioServiceBackgroundTaskLogger .warning("Player interrupted: ${e.message}"); } catch (e) { _audioServiceBackgroundTaskLogger .severe("Player error ${e.toString()}"); } queue.add(_queueFromSource()); // Sets the media item for the new queue. This will be whatever is // currently playing from the new queue (for example, the first song in // an album). If the player is shuffling, set the index to the player's // current index. Otherwise, set it to nextInitialIndex. nextInitialIndex // is much more stable than the current index as we know the value is set // when running this function. if (_player.shuffleModeEnabled) { if (_player.currentIndex == null) { _audioServiceBackgroundTaskLogger.severe( "_player.currentIndex is null during onUpdateQueue, not setting new media item"); } else { mediaItem.add(_getQueueItem(_player.currentIndex!)); } } else { if (nextInitialIndex == null) { _audioServiceBackgroundTaskLogger.severe( "nextInitialIndex is null during onUpdateQueue, not setting new media item"); } else { mediaItem.add(_getQueueItem(nextInitialIndex!)); } } shuffleNextQueue = false; nextInitialIndex = null; } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } @override Future skipToPrevious() async { try { if (!_player.hasPrevious || _player.position.inSeconds >= 5) { await _player.seek(Duration.zero, index: _player.currentIndex); } else { await _player.seek(Duration.zero, index: _player.previousIndex); } } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } @override Future skipToNext() async { try { await _player.seekToNext(); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } Future skipToIndex(int index) async { try { await _player.seek(Duration.zero, index: index); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } @override Future seek(Duration position) async { try { await _player.seek(position); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } @override Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { try { switch (shuffleMode) { case AudioServiceShuffleMode.all: await _player.setShuffleModeEnabled(true); shuffleNextQueue = true; break; case AudioServiceShuffleMode.none: await _player.setShuffleModeEnabled(false); shuffleNextQueue = false; break; default: return Future.error( "Unsupported AudioServiceRepeatMode! Recieved ${shuffleMode.toString()}, requires all or none."); } } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { try { switch (repeatMode) { case AudioServiceRepeatMode.all: await _player.setLoopMode(LoopMode.all); break; case AudioServiceRepeatMode.none: await _player.setLoopMode(LoopMode.off); break; case AudioServiceRepeatMode.one: await _player.setLoopMode(LoopMode.one); break; default: return Future.error( "Unsupported AudioServiceRepeatMode! Received ${repeatMode.toString()}, requires all, none, or one.", ); } } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } @override Future removeQueueItemAt(int index) async { try { await _queueAudioSource.removeAt(index); queue.add(_queueFromSource()); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } /// Report track changes to the Jellyfin Server if the user is not offline. Future onTrackChanged( MediaItem currentItem, PlaybackState currentState, MediaItem? previousItem, PlaybackState? previousState, ) async { final isOffline = FinampSettingsHelper.finampSettings.isOffline; if (previousItem != null && previousState != null && // don't submit stop events for idle tracks (at position 0 and not playing) (previousState.playing || previousState.updatePosition != Duration.zero)) { if (!isOffline) { final playbackData = generatePlaybackProgressInfoFromState( previousItem, previousState, ); if (playbackData != null) { try { await _jellyfinApiHelper.stopPlaybackProgress(playbackData); } catch (e) { _offlineListenLogHelper.logOfflineListen(previousItem); } } } else { _offlineListenLogHelper.logOfflineListen(previousItem); } } if (!isOffline) { final playbackData = generatePlaybackProgressInfoFromState( currentItem, currentState, ); if (playbackData != null) { await _jellyfinApiHelper.reportPlaybackStart(playbackData); } } } /// Generates PlaybackProgressInfo for the supplied item and player info. PlaybackProgressInfo? generatePlaybackProgressInfo( MediaItem item, { required bool isPaused, required bool isMuted, required Duration playerPosition, required String repeatMode, required bool includeNowPlayingQueue, }) { try { return PlaybackProgressInfo( itemId: item.extras!["itemJson"]["Id"], isPaused: isPaused, isMuted: isMuted, positionTicks: playerPosition.inMicroseconds * 10, repeatMode: repeatMode, playMethod: item.extras!["shouldTranscode"] ?? false ? "Transcode" : "DirectPlay", // We don't send the queue since it seems useless and it can cause // issues with large queues. // https://github.com/jmshrv/finamp/issues/387 // nowPlayingQueue: includeNowPlayingQueue // ? _queueFromSource() // .map( // (e) => QueueItem( // id: e.extras!["itemJson"]["Id"], playlistItemId: e.id), // ) // .toList() // : null, ); } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); rethrow; } } /// Generates PlaybackProgressInfo from current player info. /// Returns null if _queue is empty. /// If an item is not supplied, the current queue index will be used. PlaybackProgressInfo? generateCurrentPlaybackProgressInfo() { final currentIndex = _player.currentIndex; if (_queueAudioSource.length == 0 || currentIndex == null) { // This function relies on _queue having items, // so we return null if it's empty or no index is played // and no custom item was passed to avoid more errors. return null; } final item = _getQueueItem(currentIndex); return generatePlaybackProgressInfo( item, isPaused: !_player.playing, isMuted: _player.volume == 0, playerPosition: _player.position, repeatMode: _jellyfinRepeatModeFromLoopMode(_player.loopMode), includeNowPlayingQueue: false, ); } /// Generates PlaybackProgressInfo for the supplied item and playback state. PlaybackProgressInfo? generatePlaybackProgressInfoFromState( MediaItem item, PlaybackState state, ) { final duration = item.duration; return generatePlaybackProgressInfo( item, isPaused: !state.playing, // always consider as unmuted isMuted: false, // ensure the (extrapolated) position doesn't exceed the duration playerPosition: duration != null && state.position > duration ? duration : state.position, repeatMode: _jellyfinRepeatModeFromRepeatMode(state.repeatMode), includeNowPlayingQueue: true, ); } void setNextInitialIndex(int index) { nextInitialIndex = index; } Future reorderQueue(int oldIndex, int newIndex) async { // When we're moving an item forwards, we need to reduce newIndex by 1 // to account for the current item being removed before re-insertion. if (oldIndex < newIndex) { newIndex -= 1; } await _queueAudioSource.move(oldIndex, newIndex); queue.add(_queueFromSource()); _audioServiceBackgroundTaskLogger.log(Level.INFO, "Published queue"); } /// Sets the sleep timer with the given [duration]. Timer setSleepTimer(Duration duration) { _sleepTimerIsSet = true; _sleepTimerDuration = duration; _sleepTimer.value = Timer(duration, () async { _sleepTimer.value = null; return await pause(); }); return _sleepTimer.value!; } /// Cancels the sleep timer and clears it. void clearSleepTimer() { _sleepTimerIsSet = false; _sleepTimerDuration = Duration.zero; _sleepTimer.value?.cancel(); _sleepTimer.value = null; } /// Transform a just_audio event into an audio_service state. /// /// This method is used from the constructor. Every event received from the /// just_audio player will be transformed into an audio_service state so that /// it can be broadcast to audio_service clients. PlaybackState _transformEvent(PlaybackEvent event) { return PlaybackState( controls: [ MediaControl.skipToPrevious, if (_player.playing) MediaControl.pause else MediaControl.play, MediaControl.stop, MediaControl.skipToNext, ], systemActions: const { MediaAction.seek, MediaAction.seekForward, MediaAction.seekBackward, }, androidCompactActionIndices: const [0, 1, 3], processingState: const { ProcessingState.idle: AudioProcessingState.idle, ProcessingState.loading: AudioProcessingState.loading, ProcessingState.buffering: AudioProcessingState.buffering, ProcessingState.ready: AudioProcessingState.ready, ProcessingState.completed: AudioProcessingState.completed, }[_player.processingState]!, playing: _player.playing, updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, speed: _player.speed, queueIndex: event.currentIndex, shuffleMode: _player.shuffleModeEnabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, repeatMode: _audioServiceRepeatMode(_player.loopMode), ); } Future _updatePlaybackProgress() async { try { JellyfinApiHelper jellyfinApiHelper = GetIt.instance(); final playbackInfo = generateCurrentPlaybackProgressInfo(); if (playbackInfo != null) { await jellyfinApiHelper.updatePlaybackProgress(playbackInfo); } } catch (e) { _audioServiceBackgroundTaskLogger.severe(e); return Future.error(e); } } MediaItem _getQueueItem(int index) { return _queueAudioSource.sequence[index].tag as MediaItem; } List _queueFromSource() { return _queueAudioSource.sequence.map((e) => e.tag as MediaItem).toList(); } /// Syncs the list of MediaItems (_queue) with the internal queue of the player. /// Called by onAddQueueItem and onUpdateQueue. Future _mediaItemToAudioSource(MediaItem mediaItem) async { if (mediaItem.extras!["downloadedSongJson"] == null) { // If DownloadedSong wasn't passed, we assume that the item is not // downloaded. // If offline, we throw an error so that we don't accidentally stream from // the internet. See the big comment in _songUri() to see why this was // passed in extras. if (mediaItem.extras!["isOffline"]) { return Future.error( "Offline mode enabled but downloaded song not found."); } else { if (mediaItem.extras!["shouldTranscode"] == true) { return HlsAudioSource(await _songUri(mediaItem), tag: mediaItem); } else { return AudioSource.uri(await _songUri(mediaItem), tag: mediaItem); } } } else { // We have to deserialize this because Dart is stupid and can't handle // sending classes through isolates. final downloadedSong = DownloadedSong.fromJson(mediaItem.extras!["downloadedSongJson"]); // Path verification and stuff is done in AudioServiceHelper, so this path // should be valid. final downloadUri = Uri.file(downloadedSong.file.path); return AudioSource.uri(downloadUri, tag: mediaItem); } } Future _songUri(MediaItem mediaItem) async { // We need the platform to be Android or iOS to get device info assert(Platform.isAndroid || Platform.isIOS, "_songUri() only supports Android and iOS"); // When creating the MediaItem (usually in AudioServiceHelper), we specify // whether or not to transcode. We used to pull from FinampSettings here, // but since audio_service runs in an isolate (or at least, it does until // 0.18), the value would be wrong if changed while a song was playing since // Hive is bad at multi-isolate stuff. final androidId = Platform.isAndroid ? await const AndroidId().getId() : null; final iosDeviceInfo = Platform.isIOS ? await DeviceInfoPlugin().iosInfo : null; final parsedBaseUrl = Uri.parse(_finampUserHelper.currentUser!.baseUrl); List builtPath = List.from(parsedBaseUrl.pathSegments); Map queryParameters = Map.from(parsedBaseUrl.queryParameters); // We include the user token as a query parameter because just_audio used to // have issues with headers in HLS, and this solution still works fine queryParameters["ApiKey"] = _finampUserHelper.currentUser!.accessToken; if (mediaItem.extras!["shouldTranscode"]) { builtPath.addAll([ "Audio", mediaItem.extras!["itemJson"]["Id"], "main.m3u8", ]); queryParameters.addAll({ "audioCodec": "aac", // Ideally we'd use 48kHz when the source is, realistically it doesn't // matter too much "audioSampleRate": "44100", "maxAudioBitDepth": "16", "audioBitRate": FinampSettingsHelper.finampSettings.transcodeBitrate.toString(), }); } else { builtPath.addAll([ "Items", mediaItem.extras!["itemJson"]["Id"], "File", ]); } return Uri( host: parsedBaseUrl.host, port: parsedBaseUrl.port, scheme: parsedBaseUrl.scheme, userInfo: parsedBaseUrl.userInfo, pathSegments: builtPath, queryParameters: queryParameters, ); } } AudioServiceRepeatMode _audioServiceRepeatMode(LoopMode loopMode) { switch (loopMode) { case LoopMode.off: return AudioServiceRepeatMode.none; case LoopMode.one: return AudioServiceRepeatMode.one; case LoopMode.all: return AudioServiceRepeatMode.all; } } String _jellyfinRepeatModeFromLoopMode(LoopMode loopMode) { switch (loopMode) { case LoopMode.off: return "RepeatNone"; case LoopMode.one: return "RepeatOne"; case LoopMode.all: return "RepeatAll"; } } String _jellyfinRepeatModeFromRepeatMode(AudioServiceRepeatMode repeatMode) { switch (repeatMode) { case AudioServiceRepeatMode.none: return "RepeatNone"; case AudioServiceRepeatMode.one: return "RepeatOne"; case AudioServiceRepeatMode.all: case AudioServiceRepeatMode.group: return "RepeatAll"; } }