Pato05
2a5a51e43f
fix lyrics add right click action to AlbumCard add desktop file script for linux automated tarball creation for linux don't preload old queue
967 lines
31 KiB
Dart
967 lines
31 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
|
import 'package:audio_session/audio_session.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:freezer/api/cache.dart';
|
|
import 'package:freezer/api/deezer.dart';
|
|
import 'package:freezer/api/audio_sources/deezer_audio_source.dart';
|
|
import 'package:freezer/api/audio_sources/offline_audio_source.dart';
|
|
import 'package:freezer/api/paths.dart';
|
|
import 'package:freezer/api/player/player_helper.dart';
|
|
import 'package:freezer/api/audio_sources/url_audio_source.dart';
|
|
import 'package:freezer/api/player/systray.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:collection/collection.dart';
|
|
import 'package:scrobblenaut/scrobblenaut.dart';
|
|
|
|
import '../definitions.dart';
|
|
import '../../settings.dart';
|
|
|
|
import 'dart:io';
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
PlayerHelper playerHelper = PlayerHelper();
|
|
late AudioHandler audioHandler;
|
|
bool failsafe = false;
|
|
|
|
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');
|
|
|
|
bool _disposed = false;
|
|
|
|
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
|
|
List<StreamSubscription> _subscriptions = [];
|
|
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;
|
|
|
|
/// Last track's id
|
|
String? _lastTrackId;
|
|
|
|
// 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 (in SECONDS)
|
|
int? _timestamp;
|
|
|
|
bool _ignoreInterruptions = false;
|
|
|
|
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(_start));
|
|
return;
|
|
}
|
|
unawaited(_start(initArgs));
|
|
}
|
|
|
|
Future<void> _start(AudioPlayerTaskInitArguments initArgs) async {
|
|
// Linux and Windows support
|
|
JustAudioMediaKit.ensureInitialized();
|
|
JustAudioMediaKit.title = 'Freezer';
|
|
JustAudioMediaKit.protocolWhitelist = const ['http'];
|
|
//JustAudioMediaKit.bufferSize = 128;
|
|
|
|
_deezerAPI = initArgs.deezerAPI;
|
|
_androidAuto = AndroidAuto(deezerAPI: _deezerAPI);
|
|
_shouldLogTracks = initArgs.logListen;
|
|
_seekAsSkip = initArgs.seekAsSkip;
|
|
_ignoreInterruptions = initArgs.ignoreInterruptions;
|
|
|
|
final session = await AudioSession.instance;
|
|
await session.configure(const AudioSessionConfiguration.music());
|
|
|
|
_box = await Hive.openLazyBox('playback', path: await Paths.cacheDir());
|
|
_init();
|
|
|
|
await _loadQueueFile();
|
|
|
|
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
|
|
unawaited(sysTray.init());
|
|
}
|
|
|
|
if (initArgs.lastFMUsername != null && initArgs.lastFMPassword != null) {
|
|
unawaited(
|
|
_authorizeLastFM(initArgs.lastFMUsername!, initArgs.lastFMPassword!));
|
|
}
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
_player = AudioPlayer(
|
|
handleInterruptions: !_ignoreInterruptions,
|
|
androidApplyAudioAttributes: true,
|
|
handleAudioSessionActivation: true,
|
|
);
|
|
|
|
_subscriptions = [
|
|
_player.currentIndexStream.listen((index) {
|
|
if (index != null && queue.value.isNotEmpty) {
|
|
// Update track index + update media item
|
|
_queueIndex = index;
|
|
mediaItem.add(currentMediaItem);
|
|
|
|
// log previous track
|
|
if (index != 0 &&
|
|
_lastTrackId != null &&
|
|
_lastTrackId! != currentMediaItem.id) {
|
|
unawaited(_logListenedTrack(_lastTrackId!,
|
|
sync: _amountPaused == 0 && _amountSeeked == 0));
|
|
}
|
|
|
|
_lastTrackId = currentMediaItem.id;
|
|
_amountSeeked = 0;
|
|
_amountPaused = 0;
|
|
_timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
|
|
//LastFM
|
|
if (_queueIndex >= queue.value.length) return;
|
|
if (_scrobblenaut != null && currentMediaItem.id != _loggedTrackId) {
|
|
_loggedTrackId = currentMediaItem.id;
|
|
unawaited(_scrobblenaut!.track.scrobble(
|
|
track: currentMediaItem.title,
|
|
artist: currentMediaItem.artist!,
|
|
album: currentMediaItem.album,
|
|
duration: currentMediaItem.duration,
|
|
));
|
|
}
|
|
}
|
|
|
|
if (index == queue.value.length - 1) {
|
|
// if the queue is ended, load more tracks if applicable
|
|
unawaited(_onQueueEnd());
|
|
}
|
|
}),
|
|
//Update state on all clients on change
|
|
_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) {
|
|
_onQueueEnd();
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}),
|
|
|
|
_player.bufferedPositionStream.listen((bufferPosition) {
|
|
customEvent.add({'action': 'bufferPosition', 'data': bufferPosition});
|
|
}),
|
|
];
|
|
|
|
//Audio session
|
|
// _audioSessionSubscription =
|
|
// _player.androidAudioSessionIdStream.listen((event) {
|
|
// customEvent.add({'action': 'audioSession', 'id': event});
|
|
// });
|
|
|
|
// Determine audio quality to use (based on whether mobile or wifi)
|
|
// also checks if we can use the Connectivity plugin on this platform
|
|
// ex. Linux without NetworkManager
|
|
if (await _determineAudioQuality()) {
|
|
// listen for connectivity changes
|
|
_subscriptions.add(Connectivity()
|
|
.onConnectivityChanged
|
|
.listen(_determineAudioQualityByResult));
|
|
}
|
|
}
|
|
|
|
Future<void> _maybeResume() {
|
|
if (!_disposed) return Future.value();
|
|
|
|
return _init();
|
|
}
|
|
|
|
/// 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 {
|
|
await _maybeResume();
|
|
_lastPosition = null;
|
|
// next or prev track?
|
|
unawaited(_logListenedTrack(
|
|
currentMediaItem.id,
|
|
sync: false,
|
|
next: _queueIndex + 1 == index,
|
|
prev: _queueIndex - 1 == index,
|
|
));
|
|
//Skip in player
|
|
await _player.seek(Duration.zero, index: index);
|
|
_queueIndex = index;
|
|
play();
|
|
}
|
|
|
|
@override
|
|
Future play() async {
|
|
await _maybeResume();
|
|
_player.play();
|
|
//Restore position on play
|
|
if (_lastPosition != null) {
|
|
_player.seek(_lastPosition);
|
|
_lastPosition = null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> pause() {
|
|
_amountPaused++;
|
|
return _player.pause();
|
|
}
|
|
|
|
@override
|
|
Future<void> seek(Duration? position) async {
|
|
await _maybeResume();
|
|
_amountSeeked++;
|
|
return _player.seek(position);
|
|
}
|
|
|
|
@override
|
|
Future<void> fastForward() async {
|
|
await _maybeResume();
|
|
print('fast forward called');
|
|
if (currentMediaItemIsShow) {
|
|
return _seekRelative(const Duration(seconds: 30));
|
|
}
|
|
|
|
if (_seekAsSkip) return skipToNext();
|
|
|
|
return Future.value();
|
|
}
|
|
|
|
@override
|
|
Future<void> rewind() async {
|
|
await _maybeResume();
|
|
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();
|
|
|
|
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 {
|
|
await _maybeResume();
|
|
_lastPosition = null;
|
|
if (_queueIndex == queue.value.length - 1) return;
|
|
//Update buffering state
|
|
unawaited(_logListenedTrack(currentMediaItem.id, sync: false, next: true));
|
|
_queueIndex++;
|
|
await _player.seekToNext();
|
|
_broadcastState();
|
|
}
|
|
|
|
@override
|
|
Future<void> skipToPrevious() async {
|
|
await _maybeResume();
|
|
if (_queueIndex == 0) return;
|
|
//Update buffering state
|
|
//_skipState = AudioProcessingState.skippingToPrevious;
|
|
//Normal skip to previous
|
|
unawaited(_logListenedTrack(currentMediaItem.id, sync: false, prev: true));
|
|
_queueIndex--;
|
|
await _player.seekToPrevious();
|
|
//_skipState = null;
|
|
}
|
|
|
|
Future<void> _logListenedTrack(String trackId,
|
|
{required bool sync, bool next = false, bool prev = false}) async {
|
|
if (_loggedTrackId == trackId) return;
|
|
_loggedTrackId = trackId;
|
|
|
|
print(
|
|
'logging: seek: $_amountSeeked, pause: $_amountPaused, sync: $sync, next: $next, prev: $prev, timestamp: $_timestamp (elapsed: ${DateTime.now().millisecondsSinceEpoch ~/ 1000 - _timestamp!}s)');
|
|
if (!_shouldLogTracks) return;
|
|
|
|
//Log to Deezer
|
|
_deezerAPI.logListen(
|
|
trackId,
|
|
seek: _amountSeeked,
|
|
pause: _amountPaused,
|
|
sync: sync ? 1 : 0,
|
|
timestamp: _timestamp,
|
|
prev: prev,
|
|
next: next,
|
|
);
|
|
}
|
|
|
|
@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.custom);
|
|
}
|
|
|
|
return const MediaControl(
|
|
androidIcon: 'drawable/ic_heart_outline',
|
|
label: 'favourite',
|
|
action: MediaAction
|
|
.custom, // 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,
|
|
/**/ 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
|
|
? {
|
|
MediaAction.seek,
|
|
MediaAction.seekBackward,
|
|
if (queue.hasValue && _queueIndex != queue.value.length - 1)
|
|
MediaAction.skipToNext,
|
|
if (_queueIndex != 0) MediaAction.skipToPrevious,
|
|
MediaAction.stop
|
|
}
|
|
: const {
|
|
MediaAction.seek,
|
|
MediaAction.fastForward,
|
|
MediaAction.rewind,
|
|
},
|
|
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;
|
|
|
|
// 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({bool preload = true}) 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: preload);
|
|
} 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']);
|
|
|
|
// DON'T CARE, WE DON'T NEED THOSE WITH NEW API
|
|
// 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,
|
|
// THESE NEXT 4 CAN BE NULL.
|
|
// IF THEY ARE NULL, THEY'RE GONNA BE FETCHED LATER ON.
|
|
trackToken: mediaItem.extras!['trackToken'],
|
|
trackTokenExpiration: mediaItem.extras!['trackTokenExpiration'],
|
|
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();
|
|
_disposed = true;
|
|
_player.dispose();
|
|
for (final subscription in _subscriptions) {
|
|
subscription.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) {
|
|
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) return;
|
|
|
|
_queueAutoIncrement = q.length;
|
|
queue.add(q);
|
|
await _loadQueue(preload: false);
|
|
}
|
|
|
|
@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) {
|
|
// TODO: migrate to native implementation once we fix this in just_audio_media_kit
|
|
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;
|
|
case 'searchMix':
|
|
tracks = await _deezerAPI.getSearchTrackMix(queueSource!.id!, null);
|
|
default:
|
|
return;
|
|
// print(queueSource.toJson());
|
|
}
|
|
|
|
if (tracks == null) {
|
|
throw Exception(
|
|
'Failed to fetch more queue songs (queueSource: ${queueSource!.toJson()})');
|
|
}
|
|
|
|
final mi =
|
|
tracks.map<MediaItem>((t) => t.toMediaItem()).toList(growable: false);
|
|
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;
|
|
}
|
|
}
|