freezer/lib/api/player/audio_handler.dart

1073 lines
35 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();
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);
}
}
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]) {
unawaited(_start(initArgs!));
}
Future<void> _start(AudioPlayerTaskInitArguments initArgs) async {
// Linux and Windows support
JustAudioMediaKit.ensureInitialized();
JustAudioMediaKit.title = 'Freezer';
JustAudioMediaKit.protocolWhitelist = const ['http'];
JustAudioMediaKit.prefetchPlaylist = true;
//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();
}
//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;
unawaited(_logListenedTrack(currentMediaItem.id, sync: false, prev: true));
_queueIndex--;
await _player.seekToPrevious();
}
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'));
}
Future<void> toggleFavoriteCurrent() async {
// (un)favourite action
if (cache.libraryTracks.contains(currentMediaItem.id)) {
cache.libraryTracks.remove(currentMediaItem.id);
_broadcastState();
await _deezerAPI.removeFavorite(currentMediaItem.id);
return;
}
cache.libraryTracks.add(currentMediaItem.id);
_broadcastState();
await _deezerAPI.addFavoriteTrack(currentMediaItem.id);
}
//Update state on all clients
void _broadcastState() {
final controls = !currentMediaItemIsShow
? [
if (queue.value.isNotEmpty) favoriteControl(),
if (_queueIndex != 0) MediaControl.skipToPrevious,
_player.playing ? MediaControl.pause : MediaControl.play,
MediaControl.stop,
if (queue.hasValue && _queueIndex != queue.value.length - 1)
MediaControl.skipToNext,
]
: [
const MediaControl(
androidIcon: 'drawable/ic_replay_30',
label: 'replay 30',
action: MediaAction.rewind,
), // acts as prev-30
_player.playing ? MediaControl.pause : MediaControl.play,
MediaControl.stop,
const MediaControl(
androidIcon: 'drawable/ic_forward_30',
label: 'forward 30',
action: MediaAction.fastForward,
), // next-30
];
playbackState.add(PlaybackState(
controls: controls,
systemActions: !currentMediaItemIsShow
? const {
MediaAction.seek,
MediaAction.seekForward,
MediaAction.seekBackward,
}
: 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: List.generate(controls.length, (i) => i)
.whereNot((i) => controls[i].action == MediaAction.stop)
.toList(growable: false),
repeatMode: _repeatMode,
shuffleMode: _originalQueue == null
? AudioServiceShuffleMode.none
: AudioServiceShuffleMode.all,
));
}
//just_audio state -> audio_service state.
AudioProcessingState _convertProcessingState(
ProcessingState processingState) {
return switch (processingState) {
ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.buffering => AudioProcessingState.buffering,
ProcessingState.ready => AudioProcessingState.ready,
ProcessingState.completed => AudioProcessingState.completed,
};
}
//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':
print('updateQuality($extras)');
//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;
case 'favorite':
toggleFavoriteCurrent();
//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;
}
}