use get_url api by default, and fall back to old generation if get_url failed start to write a better cachemanager to implement in all systems write in more appropriate directories on windows and linux improve check for Connectivity by adding a fallback (needed for example on linux systems without NetworkManager) allow to dynamically change track quality without rebuilding the object
939 lines
30 KiB
Dart
939 lines
30 KiB
Dart
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/paths.dart';
|
|
import 'package:freezer/api/player/player_helper.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:just_audio_media_kit/just_audio_media_kit.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 'package:media_kit/media_kit.dart' show MPVLogLevel;
|
|
|
|
import '../definitions.dart';
|
|
import '../../settings.dart';
|
|
|
|
import 'dart:io';
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
PlayerHelper playerHelper = PlayerHelper();
|
|
late AudioHandler audioHandler;
|
|
|
|
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<AudioPlayerTaskInitArguments> 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<MediaItem>? _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;
|
|
bool _isConnectivityPluginAvailable = true;
|
|
|
|
/// 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<void> _init(AudioPlayerTaskInitArguments initArgs) async {
|
|
// Linux/Windows specific options
|
|
JustAudioMediaKit.title = 'Freezer';
|
|
JustAudioMediaKit.protocolWhitelist = const ['http'];
|
|
JustAudioMediaKit.mpvLogLevel = MPVLogLevel.debug;
|
|
|
|
_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 Paths.cacheDir());
|
|
|
|
_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) {
|
|
_amountSeeked = 0;
|
|
_amountPaused = 0;
|
|
_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
|
|
if (await _determineAudioQuality()) {
|
|
// listen for connectivity changes
|
|
_connectivitySubscription = Connectivity()
|
|
.onConnectivityChanged
|
|
.listen(_determineAudioQualityByResult);
|
|
}
|
|
|
|
await _loadQueueFile();
|
|
|
|
if (initArgs.lastFMUsername != null && initArgs.lastFMPassword != null) {
|
|
await _authorizeLastFM(
|
|
initArgs.lastFMUsername!, initArgs.lastFMPassword!);
|
|
}
|
|
|
|
customEvent.add({'action': 'onLoad'});
|
|
}
|
|
|
|
/// Determine the [AudioQuality] to use according to current connection
|
|
///
|
|
/// Returns whether the [Connectivity] plugin is available on this system or not
|
|
Future<bool> _determineAudioQuality() async {
|
|
if (_isConnectivityPluginAvailable) {
|
|
try {
|
|
await Connectivity()
|
|
.checkConnectivity()
|
|
.then(_determineAudioQualityByResult);
|
|
return true;
|
|
} catch (e) {
|
|
_isConnectivityPluginAvailable = false;
|
|
customEvent.add({'action': 'connectivityPlugin', 'available': false});
|
|
}
|
|
}
|
|
|
|
_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
|
|
_determineAudioQualityByResult(ConnectivityResult.other);
|
|
return false;
|
|
}
|
|
|
|
/// Determines the [AudioQuality] to use according to [result]
|
|
void _determineAudioQualityByResult(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;
|
|
}
|
|
|
|
print('quality: $_currentQuality');
|
|
}
|
|
|
|
@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<void> pause() {
|
|
_amountPaused++;
|
|
return _player.pause();
|
|
}
|
|
|
|
@override
|
|
Future<void> seek(Duration? position) {
|
|
_amountSeeked++;
|
|
return _player.seek(position);
|
|
}
|
|
|
|
@override
|
|
Future<void> fastForward() {
|
|
print('fast forward called');
|
|
if (currentMediaItemIsShow) {
|
|
return _seekRelative(const Duration(seconds: 30));
|
|
}
|
|
|
|
if (_seekAsSkip) return skipToNext();
|
|
|
|
return Future.value();
|
|
}
|
|
|
|
@override
|
|
Future<void> rewind() {
|
|
print('rewind called');
|
|
if (currentMediaItemIsShow) {
|
|
return _seekRelative(-const Duration(seconds: 30));
|
|
}
|
|
|
|
if (_seekAsSkip) return skipToPrevious();
|
|
|
|
return Future.value();
|
|
}
|
|
|
|
// unused.
|
|
@override
|
|
Future<void> seekForward(bool begin) => Future.value();
|
|
|
|
@override
|
|
Future<void> 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<void> removeQueueItem(MediaItem mediaItem) async {
|
|
int index = queue.value.indexWhere((m) => m.id == mediaItem.id);
|
|
removeQueueItemAt(index);
|
|
}
|
|
|
|
@override
|
|
Future<void> removeQueueItemAt(int index) async {
|
|
if (index <= _queueIndex) {
|
|
_queueIndex--;
|
|
}
|
|
|
|
await _audioSource.removeAt(index);
|
|
|
|
queue.add(queue.value..removeAt(index));
|
|
}
|
|
|
|
@override
|
|
Future<void> skipToNext() async {
|
|
_lastPosition = null;
|
|
if (_queueIndex == queue.value.length - 1) return;
|
|
//Update buffering state
|
|
unawaited(_logListenedTrack(currentMediaItem));
|
|
_queueIndex++;
|
|
await _player.seekToNext();
|
|
_broadcastState();
|
|
}
|
|
|
|
@override
|
|
Future<void> 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<void> _logListenedTrack(MediaItem mediaItem) async {
|
|
print(
|
|
'logging: seek: $_amountSeeked, pause: $_amountPaused, timestamp: $_timestamp (elapsed: ${DateTime.now().millisecondsSinceEpoch - _timestamp!}ms)');
|
|
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<List<MediaItem>> getChildren(String parentMediaId,
|
|
[Map<String, dynamic>? 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, AudioProcessingState>{
|
|
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<MediaItem> 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<AudioSource> _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,
|
|
trackToken: mediaItem.extras!['trackToken'] ?? '',
|
|
trackTokenExpiration: mediaItem.extras!['trackTokenExpiration'] ?? 0,
|
|
md5origin: playbackDetails![0],
|
|
mediaVersion: playbackDetails[1],
|
|
onStreamObtained: (qualityInfo) =>
|
|
customEvent.add({'action': 'streamInfo', 'data': qualityInfo}),
|
|
);
|
|
}
|
|
|
|
//Custom actions
|
|
@override
|
|
Future customAction(String name, [Map<String, dynamic>? 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;
|
|
_determineAudioQuality();
|
|
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<double> 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<void> stop() async {
|
|
await _saveQueue();
|
|
_player.stop();
|
|
_eventSubscription?.cancel();
|
|
_audioSessionSubscription?.cancel();
|
|
_visualizerSubscription?.cancel();
|
|
_bufferPositionSubscription?.cancel();
|
|
_connectivitySubscription?.cancel();
|
|
await super.stop();
|
|
}
|
|
|
|
//Export queue to -JSON- hive box
|
|
Future<void> _saveQueue() async {
|
|
if (_queueIndex == 0 && queue.value.isEmpty) return;
|
|
await _box.clear();
|
|
await _box.putAll(<String, dynamic>{
|
|
'index': _queueIndex,
|
|
'queue': queue.value,
|
|
'position': _player.position,
|
|
'queueSource': queueSource,
|
|
'repeatMode': _repeatMode,
|
|
});
|
|
}
|
|
|
|
//Restore queue & playback info from path
|
|
Future<void> _loadQueueFile() async {
|
|
if (_box.isEmpty) {
|
|
await _loadQueue();
|
|
return;
|
|
}
|
|
|
|
final q = ((await _box.get('queue')) as List?)?.cast<MediaItem>();
|
|
_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<void> 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<void> 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<void> addQueueItems(List<MediaItem> mediaItems) async {
|
|
for (final mediaItem in mediaItems) {
|
|
await addQueueItem(mediaItem, shouldSaveQueue: false);
|
|
}
|
|
_saveQueue();
|
|
}
|
|
|
|
@override
|
|
Future<void> playFromMediaId(String mediaId,
|
|
[Map<String, dynamic>? 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<MediaItem?> getMediaItem(String mediaId) async =>
|
|
queue.value.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId);
|
|
|
|
@override
|
|
Future<void> playMediaItem(MediaItem mediaItem) =>
|
|
playFromMediaId(mediaItem.id);
|
|
|
|
@override
|
|
Future<void> setRepeatMode(AudioServiceRepeatMode repeatMode) =>
|
|
_player.setLoopMode(repeatMode.toLoopMode());
|
|
|
|
@override
|
|
Future<void> 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<void> _onQueueEnd() async {
|
|
if (queueSource == null) return;
|
|
|
|
List<Track>? 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<String> 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<Future<MediaItem>>((t) => t.toMediaItem()));
|
|
await addQueueItems(mi);
|
|
}
|
|
|
|
Future<void> _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;
|
|
}
|
|
}
|