freezer/lib/api/player/audio_handler.dart
Pato05 87c9733f51
add build script for linux
fix audio service stop on android
getTrack backend improvements
get new track token when expired
move shuffle button into LibraryPlaylists as FAB
move favoriteButton next to track title
move lyrics button on top of album art
search: fix chips, and remove checkbox when selected
2024-02-19 00:49:32 +01:00

1093 lines
36 KiB
Dart

import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.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 AudioPlayerTask 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');
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;
/// Last playback queueIndex (used for restoring when player died)
int? _lastQueueIndex;
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(shouldLoadQueue: false);
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({required bool shouldLoadQueue}) 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));
}
if (shouldLoadQueue) {
await _loadQueue(preload: true);
}
}
/// 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;
_lastQueueIndex = 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 {
_logger.fine('playing...');
await _player.play();
//Restore position and queue index on play
if (_lastPosition != null) {
_player.seek(_lastPosition, index: _lastQueueIndex);
_lastPosition = null;
_lastQueueIndex = null;
}
}
@override
Future<void> pause() {
_amountPaused++;
return _player.pause();
}
@override
Future<void> seek(Duration? position) async {
_amountSeeked++;
return _player.seek(position);
}
@override
Future<void> fastForward() async {
print('fast forward called');
if (currentMediaItemIsShow) {
return _seekRelative(const Duration(seconds: 30));
}
if (_seekAsSkip) return skipToNext();
return Future.value();
}
@override
Future<void> rewind() async {
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 {
_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 {
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,
customAction: CustomMediaAction(name: 'favorite'));
}
return const MediaControl(
androidIcon: 'drawable/ic_heart_outline',
label: 'favourite',
action: MediaAction.custom,
customAction: CustomMediaAction(name: 'favorite'));
}
//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,
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,
androidCompactActionIndices:
!currentMediaItemIsShow ? const [1, 2, 3] : const [0, 1, 2],
repeatMode: _repeatMode,
shuffleMode: _originalQueue == null
? AudioServiceShuffleMode.none
: AudioServiceShuffleMode.all,
));
}
//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();
// save state
await _player.stop();
// await _player.dispose();
// for (final subscription in _subscriptions) {
// await subscription.cancel();
// }
await super.stop();
}
Future<void> dispose() async {
await _saveQueue();
await _player.dispose();
for (final subscription in _subscriptions) {
await subscription.cancel();
}
}
//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>();
_lastQueueIndex = _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);
//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<List<MediaItem>> search(String query, [Map<String, dynamic>? extras]) {
_logger.fine('search($query, $extras)');
return Future.value([]);
}
@override
Future<void> playFromSearch(String query,
[Map<String, dynamic>? extras]) async {
_logger.fine('playFromSearch($query, $extras)');
// play <query> from Freezer
final res = await _deezerAPI.search(query);
print(res);
if (res.topResult != null && res.topResult!.isNotEmpty) {
_logger.fine('playing from top result: ${jsonEncode(res.topResult![0])}');
final top = res.topResult![0];
switch (top) {
case final Track track:
unawaited(playerHelper.playSearchMix(track.id, track.title!));
break;
case final Artist artist:
final fullArtist = await _deezerAPI.artist(artist.id);
unawaited(playerHelper.playFromTopTracks(
fullArtist.topTracks!, null, fullArtist));
break;
case final Album album:
final fullAlbum = await _deezerAPI.album(album.id);
unawaited(playerHelper.playFromAlbum(fullAlbum));
break;
case final Playlist playlist:
final fullPlaylist = await _deezerAPI.playlist(playlist.id);
unawaited(playerHelper.playFromPlaylist(fullPlaylist));
break;
}
} else if (res.tracks != null && res.tracks!.isNotEmpty) {
_logger.fine('playing from track mix: ${jsonEncode(res.tracks![0])}');
unawaited(
playerHelper.playSearchMix(res.tracks![0].id, res.tracks![0].title!));
} else if (res.albums != null && res.albums!.isNotEmpty) {
_logger.fine('playing from album: ${jsonEncode(res.albums![0])}');
unawaited(playerHelper
.playFromAlbum(await _deezerAPI.album(res.albums![0].id)));
} else if (res.artists != null && res.artists!.isNotEmpty) {
_logger.fine('playing from artist top: ${jsonEncode(res.artists![0])}');
final artist = await _deezerAPI.artist(res.artists![0].id);
unawaited(
playerHelper.playFromTopTracks(artist.topTracks!, null, artist));
} else if (res.shows != null && res.shows!.isNotEmpty) {
_logger.fine('playing from show: ${jsonEncode(res.shows![0])}');
final episodes = await _deezerAPI.allShowEpisodes(res.shows![0].id);
unawaited(playerHelper.playShowEpisode(res.shows![0], episodes!));
}
}
@override
Future<void> playFromUri(Uri uri, [Map<String, dynamic>? extras]) async {
_logger.fine('playFromUri($uri, $extras)');
if (uri.host != 'www.deezer.com' && uri.host != 'deezer.com') {
return;
}
// parse common urls
if (uri.path.startsWith('/user/me/') && uri.pathSegments.length == 3) {
String stlId = uri.pathSegments[2];
if ([
'new-releases',
'inspired-by-1',
'inspired-by-2',
'inspired-by-3',
'inspired-by-4',
'discovery',
'flow'
].contains(stlId)) {
await playerHelper.playFromSmartTrackList(SmartTrackList(id: stlId));
return;
}
}
final parsed = await _deezerAPI.parseLink(uri);
if (parsed == null) return;
switch (parsed.type!) {
case DeezerMediaType.track:
final track = await _deezerAPI.track(parsed.id!);
_logger.fine('playing from track mix: ${jsonEncode(track)}');
unawaited(playerHelper.playSearchMix(track.id, track.title!));
break;
case DeezerMediaType.album:
final album = await _deezerAPI.album(parsed.id!);
_logger.fine('playing from album: ${album.title}');
unawaited(playerHelper.playFromAlbum(album));
break;
case DeezerMediaType.artist:
final artist = await _deezerAPI.artist(parsed.id!);
_logger.fine('playing from artist top: ${artist.name!}');
unawaited(
playerHelper.playFromTopTracks(artist.topTracks!, null, artist));
break;
case DeezerMediaType.playlist:
final fullPlaylist = await _deezerAPI.playlist(parsed.id!);
_logger.fine('playing from playlist: ${fullPlaylist.title!}');
unawaited(playerHelper.playFromPlaylist(fullPlaylist));
break;
default:
break;
}
}
@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;
}
}