first commit
This commit is contained in:
@@ -0,0 +1,798 @@
|
||||
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 = <int>[];
|
||||
|
||||
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<JellyfinApiHelper>();
|
||||
final _offlineListenLogHelper = GetIt.instance<OfflineListenLogHelper>();
|
||||
final _finampUserHelper = GetIt.instance<FinampUserHelper>();
|
||||
|
||||
/// 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<Timer?> _sleepTimer = ValueNotifier<Timer?>(null);
|
||||
|
||||
List<int>? get shuffleIndices => _player.shuffleIndices;
|
||||
|
||||
ValueListenable<Timer?> 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<void> 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<void> pause() => _player.pause();
|
||||
|
||||
@override
|
||||
Future<void> 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<void> addQueueItem(MediaItem mediaItem) async {
|
||||
addQueueItems([mediaItem]);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addQueueItems(List<MediaItem> 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<void> insertQueueItemsNext(List<MediaItem> 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<void> updateQueue(List<MediaItem> newQueue) async {
|
||||
try {
|
||||
// Convert the MediaItems to AudioSources
|
||||
List<AudioSource> 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<void> 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<void> skipToNext() async {
|
||||
try {
|
||||
await _player.seekToNext();
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> skipToIndex(int index) async {
|
||||
try {
|
||||
await _player.seek(Duration.zero, index: index);
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) async {
|
||||
try {
|
||||
await _player.seek(position);
|
||||
} catch (e) {
|
||||
_audioServiceBackgroundTaskLogger.severe(e);
|
||||
return Future.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> _updatePlaybackProgress() async {
|
||||
try {
|
||||
JellyfinApiHelper jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
|
||||
|
||||
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<MediaItem> _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<AudioSource> _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<Uri> _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<String> builtPath = List.from(parsedBaseUrl.pathSegments);
|
||||
|
||||
Map<String, String> 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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user