Pato05
950969b774
add favorite button in notifications\nfix android tv\ntry to fix library tracks (unsuccessful)\nbetter MenuSheet
1135 lines
36 KiB
Dart
1135 lines
36 KiB
Dart
import 'package:audio_service/audio_service.dart';
|
|
import 'package:audio_session/audio_session.dart';
|
|
import 'package:equalizer/equalizer.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/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: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 'definitions.dart';
|
|
import '../settings.dart';
|
|
|
|
import 'dart:io';
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
PlayerHelper playerHelper = PlayerHelper();
|
|
late AudioHandler audioHandler;
|
|
|
|
class PlayerHelper {
|
|
late StreamSubscription _customEventSubscription;
|
|
late StreamSubscription _mediaItemSubscription;
|
|
late StreamSubscription _playbackStateStreamSubscription;
|
|
QueueSource? queueSource;
|
|
AudioServiceRepeatMode repeatType = AudioServiceRepeatMode.none;
|
|
int? audioSession;
|
|
int? _prevAudioSession;
|
|
bool equalizerOpen = false;
|
|
bool _shuffleEnabled = false;
|
|
int _queueIndex = 0;
|
|
bool _started = false;
|
|
|
|
//Visualizer
|
|
// StreamController _visualizerController = StreamController.broadcast();
|
|
// Stream get visualizerStream => _visualizerController.stream;
|
|
|
|
final _streamInfoSubject = BehaviorSubject<StreamQualityInfo>();
|
|
ValueStream<StreamQualityInfo> get streamInfo => _streamInfoSubject.stream;
|
|
|
|
/// Find queue index by id
|
|
///
|
|
/// The function gets more expensive the longer the queue is and the further the element is from the beginning.
|
|
int getQueueIndexFromId() => audioHandler.mediaItem.value == null
|
|
? -1
|
|
: audioHandler.queue.value
|
|
.indexWhere((mi) => mi.id == audioHandler.mediaItem.value!.id);
|
|
|
|
int getQueueIndex() =>
|
|
audioHandler.playbackState.value.queueIndex ?? getQueueIndexFromId();
|
|
|
|
int get queueIndex => _queueIndex;
|
|
|
|
Future<void> initAudioHandler() async {
|
|
final initArgs = AudioPlayerTaskInitArguments.from(
|
|
settings: settings, deezerAPI: deezerAPI);
|
|
// initialize our audiohandler instance
|
|
audioHandler = await AudioService.init(
|
|
builder: () => AudioPlayerTask(initArgs),
|
|
config: AudioServiceConfig(
|
|
notificationColor: settings.primaryColor,
|
|
androidStopForegroundOnPause: false,
|
|
androidNotificationOngoing: false,
|
|
androidNotificationClickStartsActivity: true,
|
|
androidNotificationChannelDescription: 'Freezer',
|
|
androidNotificationChannelName: 'Freezer',
|
|
androidNotificationIcon: 'drawable/ic_logo',
|
|
preloadArtwork: false,
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> start() async {
|
|
if (_started) return;
|
|
_started = true;
|
|
//Subscribe to custom events
|
|
_customEventSubscription = audioHandler.customEvent.listen((event) async {
|
|
if (event is! Map) return;
|
|
Logger('PlayerHelper').fine("event received: ${event['action']}");
|
|
switch (event['action']) {
|
|
case 'onLoad':
|
|
//After audio_service is loaded, load queue, set quality
|
|
await settings.updateAudioServiceQuality();
|
|
break;
|
|
case 'onRestore':
|
|
//Load queueSource from isolate
|
|
queueSource = event['queueSource'] as QueueSource;
|
|
repeatType = event['repeatMode'] as AudioServiceRepeatMode;
|
|
_queueIndex = getQueueIndex();
|
|
break;
|
|
case 'audioSession':
|
|
if (!settings.enableEqualizer) break;
|
|
//Save
|
|
_prevAudioSession = audioSession;
|
|
audioSession = event['id'];
|
|
if (audioSession == null) break;
|
|
//Open EQ
|
|
if (!equalizerOpen) {
|
|
Equalizer.open(event['id']);
|
|
equalizerOpen = true;
|
|
break;
|
|
}
|
|
//Change session id
|
|
if (_prevAudioSession != audioSession) {
|
|
if (_prevAudioSession != null) {
|
|
Equalizer.removeAudioSessionId(_prevAudioSession!);
|
|
}
|
|
Equalizer.setAudioSessionId(audioSession!);
|
|
}
|
|
break;
|
|
//Visualizer data
|
|
// case 'visualizer':
|
|
// _visualizerController.add(event['data']);
|
|
// break;
|
|
case 'streamInfo':
|
|
Logger('PlayerHelper').fine("streamInfo received");
|
|
_streamInfoSubject.add(event['data'] as StreamQualityInfo);
|
|
break;
|
|
}
|
|
});
|
|
_mediaItemSubscription = audioHandler.mediaItem.listen((mediaItem) async {
|
|
if (mediaItem == null) return;
|
|
_queueIndex = getQueueIndex();
|
|
//Load more flow if last song (not using .last since it iterates through previous elements first)
|
|
|
|
//Save queue
|
|
await audioHandler.customAction('saveQueue', {});
|
|
//Add to history
|
|
if (cache.history.isNotEmpty && cache.history.last.id == mediaItem.id) {
|
|
return;
|
|
}
|
|
cache.history.add(Track.fromMediaItem(mediaItem));
|
|
cache.save();
|
|
});
|
|
|
|
//Start audio_service
|
|
// await startService(); it is already ready, there is no need to start it
|
|
}
|
|
|
|
Future<void> authorizeLastFM() async {
|
|
if (settings.lastFMUsername == null || settings.lastFMPassword == null) {
|
|
return;
|
|
}
|
|
await audioHandler.customAction('authorizeLastFM', {
|
|
'username': settings.lastFMUsername,
|
|
'password': settings.lastFMPassword
|
|
});
|
|
}
|
|
|
|
Future<bool> toggleShuffle() async {
|
|
_shuffleEnabled = !_shuffleEnabled;
|
|
await audioHandler.setShuffleMode(_shuffleEnabled
|
|
? AudioServiceShuffleMode.all
|
|
: AudioServiceShuffleMode.none);
|
|
return _shuffleEnabled;
|
|
}
|
|
|
|
bool get shuffleEnabled => _shuffleEnabled;
|
|
|
|
//Repeat toggle
|
|
Future changeRepeat() async {
|
|
//Change to next repeat type
|
|
repeatType = repeatType == AudioServiceRepeatMode.all
|
|
? AudioServiceRepeatMode.none
|
|
: repeatType == AudioServiceRepeatMode.none
|
|
? AudioServiceRepeatMode.one
|
|
: AudioServiceRepeatMode.all;
|
|
//Set repeat type
|
|
await audioHandler.setRepeatMode(repeatType);
|
|
}
|
|
|
|
//Executed before exit
|
|
Future onExit() async {
|
|
_customEventSubscription.cancel();
|
|
_playbackStateStreamSubscription.cancel();
|
|
_mediaItemSubscription.cancel();
|
|
}
|
|
|
|
//Replace queue, play specified track id
|
|
Future<void> _loadQueuePlay(List<MediaItem> queue, String? trackId) async {
|
|
await settings.updateAudioServiceQuality();
|
|
|
|
await audioHandler.customAction('setIndex', {
|
|
'index': trackId == null ? 0 : queue.indexWhere((m) => m.id == trackId)
|
|
});
|
|
await audioHandler.updateQueue(queue);
|
|
// if (queue[0].id != trackId)
|
|
// await AudioService.skipToQueueItem(trackId);
|
|
if (!audioHandler.playbackState.value.playing) audioHandler.play();
|
|
}
|
|
|
|
//Play track from album
|
|
Future playFromAlbum(Album album, [String? trackId]) async {
|
|
await playFromTrackList(album.tracks!, trackId,
|
|
QueueSource(id: album.id, text: album.title, source: 'album'));
|
|
}
|
|
|
|
//Play mix by track
|
|
Future playMix(String trackId, String trackTitle) async {
|
|
List<Track> tracks = (await deezerAPI.playMix(trackId))!;
|
|
playFromTrackList(
|
|
tracks,
|
|
tracks[0].id,
|
|
QueueSource(
|
|
id: trackId,
|
|
text: '${'Mix based on'.i18n} $trackTitle',
|
|
source: 'mix'));
|
|
}
|
|
|
|
//Play from artist top tracks
|
|
Future playFromTopTracks(
|
|
List<Track> tracks, String trackId, Artist artist) async {
|
|
await playFromTrackList(
|
|
tracks,
|
|
trackId,
|
|
QueueSource(
|
|
id: artist.id, text: 'Top ${artist.name}', source: 'topTracks'));
|
|
}
|
|
|
|
Future playFromPlaylist(Playlist playlist, [String? trackId]) async {
|
|
await playFromTrackList(playlist.tracks!, trackId,
|
|
QueueSource(id: playlist.id, text: playlist.title, source: 'playlist'));
|
|
}
|
|
|
|
//Play episode from show, load whole show as queue
|
|
Future<void> playShowEpisode(Show show, List<ShowEpisode> episodes,
|
|
{int index = 0}) async {
|
|
QueueSource queueSource =
|
|
QueueSource(id: show.id, text: show.name, source: 'show');
|
|
//Generate media items
|
|
List<MediaItem> queue =
|
|
episodes.map<MediaItem>((e) => e.toMediaItem(show)).toList();
|
|
|
|
//Load and play
|
|
// await startService(); // audioservice is ready
|
|
await settings.updateAudioServiceQuality();
|
|
await setQueueSource(queueSource);
|
|
await audioHandler.customAction('setIndex', {'index': index});
|
|
await audioHandler.updateQueue(queue);
|
|
if (!audioHandler.playbackState.value.playing) audioHandler.play();
|
|
}
|
|
|
|
//Load tracks as queue, play track id, set queue source
|
|
Future playFromTrackList(
|
|
List<Track?> tracks, String? trackId, QueueSource queueSource) async {
|
|
final queue = await Future.wait(tracks
|
|
.map<Future<MediaItem>>((track) => track!.toMediaItem())
|
|
.toList());
|
|
await setQueueSource(queueSource);
|
|
await _loadQueuePlay(queue, trackId);
|
|
}
|
|
|
|
//Load smart track list as queue, start from beginning
|
|
Future playFromSmartTrackList(SmartTrackList stl) async {
|
|
//Load from API if no tracks
|
|
if (stl.tracks == null || stl.tracks!.isEmpty) {
|
|
if (settings.offlineMode) {
|
|
Fluttertoast.showToast(
|
|
msg: "Offline mode, can't play flow or smart track lists.".i18n,
|
|
gravity: ToastGravity.BOTTOM,
|
|
toastLength: Toast.LENGTH_SHORT);
|
|
return;
|
|
}
|
|
|
|
//Flow songs cannot be accessed by smart track list call
|
|
if (stl.id! == 'flow') {
|
|
stl.tracks = await deezerAPI.flow(stl.flowConfig);
|
|
} else {
|
|
stl = await deezerAPI.smartTrackList(stl.id);
|
|
}
|
|
}
|
|
QueueSource queueSource = QueueSource(
|
|
id: stl.flowConfig ?? stl.id,
|
|
source: (stl.id == 'flow') ? 'flow' : 'smarttracklist',
|
|
text: stl.title ??
|
|
((stl.id == 'flow') ? 'Flow'.i18n : 'Smart track list'.i18n));
|
|
await playFromTrackList(stl.tracks!, stl.tracks![0].id, queueSource);
|
|
}
|
|
|
|
Future setQueueSource(QueueSource queueSource) async {
|
|
this.queueSource = queueSource;
|
|
await audioHandler.customAction('queueSource', queueSource.toJson());
|
|
}
|
|
|
|
//Reorder tracks in queue
|
|
Future reorder(int oldIndex, int newIndex) => audioHandler
|
|
.customAction('reorder', {'oldIndex': oldIndex, 'newIndex': newIndex});
|
|
|
|
//Start visualizer
|
|
// Future startVisualizer() async {
|
|
// await audioHandler.customAction('startVisualizer');
|
|
// }
|
|
|
|
//Stop visualizer
|
|
// Future stopVisualizer() async {
|
|
// await audioHandler.customAction('stopVisualizer');
|
|
// }
|
|
}
|
|
|
|
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 {
|
|
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? _eventSub;
|
|
StreamSubscription? _audioSessionSub;
|
|
StreamSubscription? _visualizerSubscription;
|
|
|
|
/// Android Auto helper class for navigation
|
|
late final AndroidAuto _androidAuto;
|
|
|
|
//Loaded from file/frontendjust
|
|
AudioQuality mobileQuality = AudioQuality.MP3_128;
|
|
AudioQuality wifiQuality = 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 {
|
|
_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 getTemporaryDirectory()).path);
|
|
|
|
if (initArgs.ignoreInterruptions) {
|
|
_player = AudioPlayer(handleInterruptions: false);
|
|
session.interruptionEventStream.listen((_) {});
|
|
session.becomingNoisyEventStream.listen((_) {});
|
|
} else {
|
|
_player = AudioPlayer();
|
|
}
|
|
|
|
//Update track index
|
|
_player.currentIndexStream.listen((index) {
|
|
_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
|
|
_eventSub = _player.playbackEventStream.listen((event) {
|
|
//Update
|
|
_broadcastState();
|
|
});
|
|
_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 ?? const QueueSource()).toJson()
|
|
});
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
//Audio session
|
|
_audioSessionSub = _player.androidAudioSessionIdStream.listen((event) {
|
|
customEvent.add({'action': 'audioSession', 'id': event});
|
|
});
|
|
|
|
//Load queue
|
|
// queue.add(_queue);
|
|
|
|
await _loadQueueFile();
|
|
|
|
if (initArgs.lastFMUsername != null && initArgs.lastFMPassword != null) {
|
|
await _authorizeLastFM(
|
|
initArgs.lastFMUsername!, initArgs.lastFMPassword!);
|
|
}
|
|
|
|
customEvent.add({'action': 'onLoad'});
|
|
}
|
|
|
|
@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 {
|
|
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(_mediaItemToAudioSource));
|
|
|
|
_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);
|
|
} 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']);
|
|
//Quality
|
|
ConnectivityResult conn = await Connectivity().checkConnectivity();
|
|
AudioQuality quality = mobileQuality;
|
|
if (conn == ConnectivityResult.wifi) quality = wifiQuality;
|
|
|
|
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(
|
|
quality: quality,
|
|
trackId: mediaItem.id,
|
|
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;
|
|
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();
|
|
_eventSub?.cancel();
|
|
_audioSessionSub?.cancel();
|
|
_visualizerSubscription?.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;
|
|
}
|
|
}
|