942 lines
29 KiB
Dart
942 lines
29 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/ui/android_auto.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:connectivity/connectivity.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:freezer/translations.i18n.dart';
|
|
import 'package:scrobblenaut/scrobblenaut.dart';
|
|
import 'package:extended_math/extended_math.dart';
|
|
|
|
import 'definitions.dart';
|
|
import '../settings.dart';
|
|
|
|
import 'dart:io';
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
|
|
PlayerHelper playerHelper = PlayerHelper();
|
|
|
|
class PlayerHelper {
|
|
|
|
StreamSubscription _customEventSubscription;
|
|
StreamSubscription _mediaItemSubscription;
|
|
StreamSubscription _playbackStateStreamSubscription;
|
|
QueueSource queueSource;
|
|
LoopMode repeatType = LoopMode.off;
|
|
Timer _timer;
|
|
Scrobblenaut scrobblenaut;
|
|
int audioSession;
|
|
int _prevAudioSession;
|
|
bool equalizerOpen = false;
|
|
|
|
//Visualizer
|
|
StreamController _visualizerController = StreamController.broadcast();
|
|
Stream get visualizerStream => _visualizerController.stream;
|
|
|
|
//Find queue index by id
|
|
int get queueIndex => AudioService.queue == null ? 0 : AudioService.queue.indexWhere((mi) => mi.id == AudioService.currentMediaItem?.id??'Random string so it returns -1');
|
|
|
|
Future start() async {
|
|
//Subscribe to custom events
|
|
_customEventSubscription = AudioService.customEventStream.listen((event) async {
|
|
if (!(event is Map)) return;
|
|
switch (event['action']) {
|
|
case 'onLoad':
|
|
//After audio_service is loaded, load queue, set quality
|
|
await settings.updateAudioServiceQuality();
|
|
await AudioService.customAction('load');
|
|
break;
|
|
case 'onRestore':
|
|
//Load queueSource from isolate
|
|
this.queueSource = QueueSource.fromJson(event['queueSource']);
|
|
repeatType = LoopMode.values[event['loopMode']];
|
|
break;
|
|
case 'queueEnd':
|
|
//If last song is played, load more queue
|
|
this.queueSource = QueueSource.fromJson(event['queueSource']);
|
|
// onQueueEnd();
|
|
break;
|
|
case 'screenAndroidAuto':
|
|
AndroidAuto androidAuto = AndroidAuto();
|
|
List<MediaItem> data = await androidAuto.getScreen(event['id']);
|
|
await AudioService.customAction('screenAndroidAuto', jsonEncode(data));
|
|
break;
|
|
case 'tracksAndroidAuto':
|
|
AndroidAuto androidAuto = AndroidAuto();
|
|
await androidAuto.playItem(event['id']);
|
|
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;
|
|
}
|
|
|
|
});
|
|
_mediaItemSubscription = AudioService.currentMediaItemStream.listen((event) {
|
|
if (event == null) return;
|
|
//Load more flow if index-1 song
|
|
if (queueIndex == AudioService.queue.length-1)
|
|
onQueueEnd();
|
|
|
|
//Save queue
|
|
AudioService.customAction('saveQueue');
|
|
//Add to history
|
|
if (cache.history == null) cache.history = [];
|
|
if (cache.history.length > 0 && cache.history.last.id == event.id) return;
|
|
cache.history.add(Track.fromMediaItem(event));
|
|
cache.save();
|
|
});
|
|
|
|
//Logging listen timer
|
|
_timer = Timer.periodic(Duration(seconds: 2), (timer) async {
|
|
if (AudioService.currentMediaItem == null || !AudioService.playbackState.playing) return;
|
|
if (AudioService.playbackState.currentPosition.inSeconds > (AudioService.currentMediaItem.duration.inSeconds * 0.75)) {
|
|
if (cache.loggedTrackId == AudioService.currentMediaItem.id) return;
|
|
cache.loggedTrackId = AudioService.currentMediaItem.id;
|
|
await cache.save();
|
|
|
|
//Log to Deezer
|
|
if (settings.logListen) {
|
|
deezerAPI.logListen(AudioService.currentMediaItem.id);
|
|
}
|
|
|
|
//LastFM
|
|
if (scrobblenaut != null) {
|
|
await scrobblenaut.track.scrobble(
|
|
track: AudioService.currentMediaItem.title,
|
|
artist: AudioService.currentMediaItem.artist,
|
|
album: AudioService.currentMediaItem.album,
|
|
);
|
|
}
|
|
}
|
|
|
|
});
|
|
|
|
//Start audio_service
|
|
await startService();
|
|
}
|
|
|
|
Future startService() async {
|
|
if (AudioService.running && AudioService.connected) return;
|
|
if (!AudioService.connected)
|
|
await AudioService.connect();
|
|
if (!AudioService.running)
|
|
await AudioService.start(
|
|
backgroundTaskEntrypoint: backgroundTaskEntrypoint,
|
|
androidEnableQueue: true,
|
|
androidStopForegroundOnPause: false,
|
|
androidNotificationOngoing: false,
|
|
androidNotificationClickStartsActivity: true,
|
|
androidNotificationChannelDescription: 'Freezer',
|
|
androidNotificationChannelName: 'Freezer',
|
|
androidNotificationIcon: 'drawable/ic_logo',
|
|
params: {'ignoreInterruptions': settings.ignoreInterruptions}
|
|
);
|
|
}
|
|
|
|
Future authorizeLastFM() async {
|
|
if (settings.lastFMUsername == null || settings.lastFMPassword == null) return;
|
|
try {
|
|
LastFM lastFM = await LastFM.authenticateWithPasswordHash(
|
|
apiKey: 'b6ab5ae967bcd8b10b23f68f42493829',
|
|
apiSecret: '861b0dff9a8a574bec747f9dab8b82bf',
|
|
username: settings.lastFMUsername,
|
|
passwordHash: settings.lastFMPassword
|
|
);
|
|
scrobblenaut = Scrobblenaut(lastFM: lastFM);
|
|
} catch (e) {}
|
|
}
|
|
|
|
Future toggleShuffle() async {
|
|
await AudioService.customAction('shuffle');
|
|
}
|
|
|
|
//Repeat toggle
|
|
Future changeRepeat() async {
|
|
//Change to next repeat type
|
|
switch (repeatType) {
|
|
case LoopMode.one:
|
|
repeatType = LoopMode.off; break;
|
|
case LoopMode.all:
|
|
repeatType = LoopMode.one; break;
|
|
default:
|
|
repeatType = LoopMode.all; break;
|
|
}
|
|
//Set repeat type
|
|
await AudioService.customAction("repeatType", LoopMode.values.indexOf(repeatType));
|
|
}
|
|
|
|
//Executed before exit
|
|
Future onExit() async {
|
|
_customEventSubscription.cancel();
|
|
_playbackStateStreamSubscription.cancel();
|
|
_mediaItemSubscription.cancel();
|
|
}
|
|
|
|
//Replace queue, play specified track id
|
|
Future _loadQueuePlay(List<MediaItem> queue, String trackId) async {
|
|
await startService();
|
|
await settings.updateAudioServiceQuality();
|
|
await AudioService.customAction('setIndex', queue.indexWhere((m) => m.id == trackId));
|
|
await AudioService.updateQueue(queue);
|
|
// if (queue[0].id != trackId)
|
|
// await AudioService.skipToQueueItem(trackId);
|
|
if (!AudioService.playbackState.playing)
|
|
AudioService.play();
|
|
}
|
|
|
|
//Called when queue ends to load more tracks
|
|
Future onQueueEnd() async {
|
|
//Flow
|
|
if (queueSource == null) return;
|
|
|
|
List<Track> tracks = [];
|
|
switch(queueSource.source) {
|
|
case 'flow':
|
|
tracks = await deezerAPI.flow();
|
|
break;
|
|
//SmartRadio/Artist radio
|
|
case 'smartradio':
|
|
tracks = await deezerAPI.smartRadio(queueSource.id);
|
|
break;
|
|
//Library shuffle
|
|
case 'libraryshuffle':
|
|
tracks = await deezerAPI.libraryShuffle(start: AudioService.queue.length);
|
|
break;
|
|
case 'mix':
|
|
tracks = await deezerAPI.playMix(queueSource.id);
|
|
// Deduplicate tracks with the same id
|
|
List<String> queueIds = AudioService.queue.map((e) => e.id).toList();
|
|
tracks.removeWhere((track) => queueIds.contains(track.id));
|
|
break;
|
|
default:
|
|
// print(queueSource.toJson());
|
|
break;
|
|
}
|
|
|
|
List<MediaItem> mi = tracks.map<MediaItem>((t) => t.toMediaItem()).toList();
|
|
await AudioService.addQueueItems(mi);
|
|
// AudioService.skipToNext();
|
|
}
|
|
|
|
//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 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();
|
|
await settings.updateAudioServiceQuality();
|
|
await setQueueSource(queueSource);
|
|
await AudioService.customAction('setIndex', index);
|
|
await AudioService.updateQueue(queue);
|
|
if (!AudioService.playbackState.playing)
|
|
AudioService.play();
|
|
}
|
|
|
|
//Load tracks as queue, play track id, set queue source
|
|
Future playFromTrackList(List<Track> tracks, String trackId, QueueSource queueSource) async {
|
|
await startService();
|
|
|
|
List<MediaItem> queue = tracks.map<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.length == 0) {
|
|
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();
|
|
} else {
|
|
stl = await deezerAPI.smartTrackList(stl.id);
|
|
}
|
|
}
|
|
QueueSource queueSource = QueueSource(
|
|
id: 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 {
|
|
await startService();
|
|
|
|
this.queueSource = queueSource;
|
|
await AudioService.customAction('queueSource', queueSource.toJson());
|
|
}
|
|
|
|
//Reorder tracks in queue
|
|
Future reorder(int oldIndex, int newIndex) async {
|
|
await AudioService.customAction('reorder', [oldIndex, newIndex]);
|
|
}
|
|
|
|
//Start visualizer
|
|
Future startVisualizer() async {
|
|
await AudioService.customAction('startVisualizer');
|
|
}
|
|
//Stop visualizer
|
|
Future stopVisualizer() async {
|
|
await AudioService.customAction('stopVisualizer');
|
|
}
|
|
|
|
}
|
|
|
|
void backgroundTaskEntrypoint() async {
|
|
AudioServiceBackground.run(() => AudioPlayerTask());
|
|
}
|
|
|
|
class AudioPlayerTask extends BackgroundAudioTask {
|
|
AudioPlayer _player;
|
|
|
|
//Queue
|
|
List<MediaItem> _queue = <MediaItem>[];
|
|
List<MediaItem> _originalQueue;
|
|
bool _shuffle = false;
|
|
int _queueIndex = 0;
|
|
ConcatenatingAudioSource _audioSource;
|
|
|
|
AudioProcessingState _skipState;
|
|
Seeker _seeker;
|
|
|
|
//Stream subscriptions
|
|
StreamSubscription _eventSub;
|
|
StreamSubscription _audioSessionSub;
|
|
StreamSubscription _visualizerSubscription;
|
|
|
|
//Loaded from file/frontend
|
|
int mobileQuality;
|
|
int wifiQuality;
|
|
QueueSource queueSource;
|
|
Duration _lastPosition;
|
|
LoopMode _loopMode = LoopMode.off;
|
|
|
|
Completer _androidAutoCallback;
|
|
|
|
MediaItem get mediaItem => _queue[_queueIndex];
|
|
|
|
@override
|
|
Future onStart(Map<String, dynamic> params) async {
|
|
|
|
final session = await AudioSession.instance;
|
|
session.configure(AudioSessionConfiguration.music());
|
|
|
|
if (params['ignoreInterruptions'] == true) {
|
|
_player = AudioPlayer(handleInterruptions: false);
|
|
session.interruptionEventStream.listen((_) {});
|
|
session.becomingNoisyEventStream.listen((_) {});
|
|
} else
|
|
_player = AudioPlayer();
|
|
|
|
//Update track index
|
|
_player.currentIndexStream.listen((index) {
|
|
if (index != null) {
|
|
_queueIndex = index;
|
|
AudioServiceBackground.setMediaItem(mediaItem);
|
|
}
|
|
});
|
|
//Update state on all clients on change
|
|
_eventSub = _player.playbackEventStream.listen((event) {
|
|
//Quality string
|
|
if (_queueIndex != -1 && _queueIndex < _queue.length) {
|
|
Map extras = mediaItem.extras;
|
|
extras['qualityString'] = '';
|
|
_queue[_queueIndex] = mediaItem.copyWith(extras: extras);
|
|
}
|
|
//Update
|
|
_broadcastState();
|
|
});
|
|
_player.processingStateStream.listen((state) {
|
|
switch(state) {
|
|
case ProcessingState.completed:
|
|
//Player ended, get more songs
|
|
if (_queueIndex == _queue.length - 1)
|
|
AudioServiceBackground.sendCustomEvent({
|
|
'action': 'queueEnd',
|
|
'queueSource': (queueSource??QueueSource()).toJson()
|
|
});
|
|
break;
|
|
case ProcessingState.ready:
|
|
//Ready to play
|
|
_skipState = null;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
//Audio session
|
|
_audioSessionSub = _player.androidAudioSessionIdStream.listen((event) {
|
|
AudioServiceBackground.sendCustomEvent({"action": 'audioSession', "id": event});
|
|
});
|
|
|
|
//Load queue
|
|
AudioServiceBackground.setQueue(_queue);
|
|
AudioServiceBackground.sendCustomEvent({'action': 'onLoad'});
|
|
}
|
|
|
|
@override
|
|
Future onSkipToQueueItem(String mediaId) async {
|
|
_lastPosition = null;
|
|
|
|
//Calculate new index
|
|
final newIndex = _queue.indexWhere((i) => i.id == mediaId);
|
|
if (newIndex == -1) return;
|
|
//Update buffering state
|
|
_skipState = newIndex > _queueIndex
|
|
? AudioProcessingState.skippingToNext
|
|
: AudioProcessingState.skippingToPrevious;
|
|
|
|
//Skip in player
|
|
await _player.seek(Duration.zero, index: newIndex);
|
|
_queueIndex = newIndex;
|
|
_skipState = null;
|
|
onPlay();
|
|
}
|
|
|
|
@override
|
|
Future onPlay() {
|
|
_player.play();
|
|
//Restore position on play
|
|
if (_lastPosition != null) {
|
|
onSeekTo(_lastPosition);
|
|
_lastPosition = null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future onPause() => _player.pause();
|
|
|
|
@override
|
|
Future onSeekTo(Duration pos) => _player.seek(pos);
|
|
|
|
@override
|
|
Future<void> onFastForward() => _seekRelative(fastForwardInterval);
|
|
|
|
@override
|
|
Future<void> onRewind() => _seekRelative(-rewindInterval);
|
|
|
|
@override
|
|
Future<void> onSeekForward(bool begin) async => _seekContinuously(begin, 1);
|
|
|
|
@override
|
|
Future<void> onSeekBackward(bool begin) async => _seekContinuously(begin, -1);
|
|
|
|
//Remove item from queue
|
|
@override
|
|
Future<void> onRemoveQueueItem(MediaItem mediaItem) async {
|
|
int index = _queue.indexWhere((m) => m.id == mediaItem.id);
|
|
_queue.removeAt(index);
|
|
if (index <= _queueIndex) {
|
|
_queueIndex--;
|
|
}
|
|
_audioSource.removeAt(index);
|
|
|
|
AudioServiceBackground.setQueue(_queue);
|
|
}
|
|
|
|
@override
|
|
Future<void> onSkipToNext() async {
|
|
if (_queueIndex == _queue.length-1) return;
|
|
//Update buffering state
|
|
_skipState = AudioProcessingState.skippingToNext;
|
|
_queueIndex++;
|
|
await _player.seekToNext();
|
|
_skipState = null;
|
|
await _broadcastState();
|
|
}
|
|
|
|
@override
|
|
Future<void> onSkipToPrevious() async {
|
|
if (_queueIndex == 0) return;
|
|
//Update buffering state
|
|
_skipState = AudioProcessingState.skippingToPrevious;
|
|
//Normal skip to previous
|
|
_queueIndex--;
|
|
await _player.seekToPrevious();
|
|
_skipState = null;
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaItem>> onLoadChildren(String parentMediaId) async {
|
|
AudioServiceBackground.sendCustomEvent({
|
|
'action': 'screenAndroidAuto',
|
|
'id': parentMediaId
|
|
});
|
|
|
|
//Wait for data from main thread
|
|
_androidAutoCallback = Completer();
|
|
List<MediaItem> data = (await _androidAutoCallback.future) as List<MediaItem>;
|
|
_androidAutoCallback = null;
|
|
return data;
|
|
}
|
|
|
|
//While seeking, jump 10s every 1s
|
|
void _seekContinuously(bool begin, int direction) {
|
|
_seeker?.stop();
|
|
if (begin) {
|
|
_seeker = Seeker(_player, Duration(seconds: 10 * direction), Duration(seconds: 1), mediaItem)..start();
|
|
}
|
|
}
|
|
|
|
//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 > mediaItem.duration) newPos = mediaItem.duration;
|
|
|
|
await _player.seek(newPos);
|
|
}
|
|
|
|
//Update state on all clients
|
|
Future _broadcastState() async {
|
|
await AudioServiceBackground.setState(
|
|
controls: [
|
|
MediaControl.skipToPrevious,
|
|
if (_player.playing) MediaControl.pause else MediaControl.play,
|
|
MediaControl.skipToNext,
|
|
//Stop
|
|
MediaControl(
|
|
androidIcon: 'drawable/ic_action_stop',
|
|
label: 'stop',
|
|
action: MediaAction.stop
|
|
),
|
|
],
|
|
systemActions: [
|
|
MediaAction.seekTo,
|
|
MediaAction.seekForward,
|
|
MediaAction.seekBackward,
|
|
MediaAction.stop
|
|
],
|
|
processingState: _getProcessingState(),
|
|
playing: _player.playing,
|
|
position: _player.position,
|
|
bufferedPosition: _player.bufferedPosition,
|
|
speed: _player.speed
|
|
);
|
|
}
|
|
|
|
//just_audio state -> audio_service state. If skipping, use _skipState
|
|
AudioProcessingState _getProcessingState() {
|
|
if (_skipState != null) return _skipState;
|
|
//SRC: audio_service example
|
|
switch (_player.processingState) {
|
|
case ProcessingState.idle:
|
|
return AudioProcessingState.stopped;
|
|
case ProcessingState.loading:
|
|
return AudioProcessingState.connecting;
|
|
case ProcessingState.buffering:
|
|
return AudioProcessingState.buffering;
|
|
case ProcessingState.ready:
|
|
return AudioProcessingState.ready;
|
|
case ProcessingState.completed:
|
|
return AudioProcessingState.completed;
|
|
default:
|
|
throw Exception("Invalid state: ${_player.processingState}");
|
|
}
|
|
}
|
|
|
|
//Replace current queue
|
|
@override
|
|
Future onUpdateQueue(List<MediaItem> q) async {
|
|
//just_audio
|
|
_player.stop();
|
|
if (_audioSource != null) _audioSource.clear();
|
|
//Filter duplicate IDs
|
|
List<MediaItem> queue = [];
|
|
for (MediaItem mi in q) {
|
|
if (queue.indexWhere((m) => mi.id == m.id) == -1)
|
|
queue.add(mi);
|
|
}
|
|
this._queue = queue;
|
|
AudioServiceBackground.setQueue(queue);
|
|
//Load
|
|
await _loadQueue();
|
|
//await _player.seek(Duration.zero, index: 0);
|
|
}
|
|
|
|
//Load queue to just_audio
|
|
Future _loadQueue() async {
|
|
//Don't reset queue index by starting player
|
|
int qi = _queueIndex;
|
|
|
|
List<AudioSource> sources = [];
|
|
for(int i=0; i<_queue.length; i++) {
|
|
AudioSource s = await _mediaItemToAudioSource(_queue[i]);
|
|
if (s != null)
|
|
sources.add(s);
|
|
}
|
|
|
|
_audioSource = ConcatenatingAudioSource(children: sources);
|
|
//Load in just_audio
|
|
try {
|
|
await _player.setAudioSource(_audioSource, initialIndex: qi, initialPosition: Duration.zero);
|
|
} catch (e) {
|
|
//Error loading tracks
|
|
}
|
|
_queueIndex = qi;
|
|
AudioServiceBackground.setMediaItem(mediaItem);
|
|
}
|
|
|
|
Future<AudioSource> _mediaItemToAudioSource(MediaItem mi) async {
|
|
String url = await _getTrackUrl(mi);
|
|
if (url == null) return null;
|
|
if (url.startsWith('http')) return ProgressiveAudioSource(Uri.parse(url));
|
|
return AudioSource.uri(Uri.parse(url), tag: mi.id);
|
|
}
|
|
|
|
Future _getTrackUrl(MediaItem mediaItem, {int quality}) async {
|
|
//Check if offline
|
|
String _offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
|
|
File f = File(p.join(_offlinePath, mediaItem.id));
|
|
if (await f.exists()) {
|
|
//return f.path;
|
|
//Stream server URL
|
|
return 'http://localhost:36958/?id=${mediaItem.id}';
|
|
}
|
|
|
|
//Show episode direct link
|
|
if (mediaItem.extras['showUrl'] != null)
|
|
return mediaItem.extras['showUrl'];
|
|
|
|
//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();
|
|
quality = mobileQuality;
|
|
if (conn == ConnectivityResult.wifi) quality = wifiQuality;
|
|
|
|
if ((playbackDetails??[]).length < 2) return null;
|
|
//String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}';
|
|
String url = 'http://localhost:36958/?q=$quality&mv=${playbackDetails[1]}&md5origin=${playbackDetails[0]}&id=${mediaItem.id}';
|
|
return url;
|
|
}
|
|
|
|
//Custom actions
|
|
@override
|
|
Future onCustomAction(String name, dynamic args) async {
|
|
switch (name) {
|
|
case 'updateQuality':
|
|
//Pass wifi & mobile quality by custom action
|
|
//Isolate can't access globals
|
|
this.wifiQuality = args['wifiQuality'];
|
|
this.mobileQuality = args['mobileQuality'];
|
|
break;
|
|
//Update queue source
|
|
case 'queueSource':
|
|
this.queueSource = QueueSource.fromJson(Map<String, dynamic>.from(args));
|
|
break;
|
|
//Looping
|
|
case 'repeatType':
|
|
_loopMode = LoopMode.values[args];
|
|
_player.setLoopMode(_loopMode);
|
|
break;
|
|
//Save queue
|
|
case 'saveQueue':
|
|
await this._saveQueue();
|
|
break;
|
|
//Load queue after some initialization in frontend
|
|
case 'load':
|
|
await this._loadQueueFile();
|
|
break;
|
|
case 'shuffle':
|
|
String originalId = mediaItem.id;
|
|
if (!_shuffle) {
|
|
_shuffle = true;
|
|
_originalQueue = List.from(_queue);
|
|
_queue.shuffle();
|
|
|
|
} else {
|
|
_shuffle = false;
|
|
_queue = _originalQueue;
|
|
_originalQueue = null;
|
|
}
|
|
|
|
//Broken
|
|
// _queueIndex = _queue.indexWhere((mi) => mi.id == originalId);
|
|
_queueIndex = 0;
|
|
AudioServiceBackground.setQueue(_queue);
|
|
AudioServiceBackground.setMediaItem(mediaItem);
|
|
await _player.stop();
|
|
await _loadQueue();
|
|
await _player.play();
|
|
break;
|
|
|
|
//Android audio callback
|
|
case 'screenAndroidAuto':
|
|
if (_androidAutoCallback != null)
|
|
_androidAutoCallback.complete(jsonDecode(args).map<MediaItem>((m) => MediaItem.fromJson(m)).toList());
|
|
break;
|
|
//Reorder tracks, args = [old, new]
|
|
case 'reorder':
|
|
await _audioSource.move(args[0], args[1]);
|
|
//Switch in queue
|
|
List<MediaItem> newQueue = List.from(_queue);
|
|
newQueue.removeAt(args[0]);
|
|
newQueue.insert(args[1], _queue[args[0]]);
|
|
_queue = newQueue;
|
|
//Update UI
|
|
AudioServiceBackground.setQueue(_queue);
|
|
_broadcastState();
|
|
break;
|
|
//Set index without affecting playback for loading
|
|
case 'setIndex':
|
|
this._queueIndex = args;
|
|
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;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future onTaskRemoved() async {
|
|
await onStop();
|
|
}
|
|
|
|
@override
|
|
Future onClose() async {
|
|
print('onClose');
|
|
await onStop();
|
|
}
|
|
|
|
Future onStop() async {
|
|
await _saveQueue();
|
|
_player.stop();
|
|
if (_eventSub != null) _eventSub.cancel();
|
|
if (_audioSessionSub != null) _audioSessionSub.cancel();
|
|
|
|
await super.onStop();
|
|
}
|
|
|
|
//Get queue save file path
|
|
Future<String> _getQueuePath() async {
|
|
Directory dir = await getApplicationDocumentsDirectory();
|
|
return p.join(dir.path, 'playback.json');
|
|
}
|
|
|
|
//Export queue to JSON
|
|
Future _saveQueue() async {
|
|
if (_queueIndex == 0 && _queue.length == 0) return;
|
|
|
|
String path = await _getQueuePath();
|
|
File f = File(path);
|
|
//Create if doesn't exist
|
|
if (! await File(path).exists()) {
|
|
f = await f.create();
|
|
}
|
|
Map data = {
|
|
'index': _queueIndex,
|
|
'queue': _queue.map<Map<String, dynamic>>((mi) => mi.toJson()).toList(),
|
|
'position': _player.position.inMilliseconds,
|
|
'queueSource': (queueSource??QueueSource()).toJson(),
|
|
'loopMode': LoopMode.values.indexOf(_loopMode??LoopMode.off)
|
|
};
|
|
await f.writeAsString(jsonEncode(data));
|
|
}
|
|
|
|
//Restore queue & playback info from path
|
|
Future _loadQueueFile() async {
|
|
File f = File(await _getQueuePath());
|
|
if (await f.exists()) {
|
|
Map<String, dynamic> json = jsonDecode(await f.readAsString());
|
|
this._queue = (json['queue']??[]).map<MediaItem>((mi) => MediaItem.fromJson(mi)).toList();
|
|
this._queueIndex = json['index'] ?? 0;
|
|
this._lastPosition = Duration(milliseconds: json['position']??0);
|
|
this.queueSource = QueueSource.fromJson(json['queueSource']??{});
|
|
this._loopMode = LoopMode.values[(json['loopMode']??0)];
|
|
//Restore queue
|
|
if (_queue != null) {
|
|
await AudioServiceBackground.setQueue(_queue);
|
|
await _loadQueue();
|
|
await AudioServiceBackground.setMediaItem(mediaItem);
|
|
}
|
|
}
|
|
//Send restored queue source to ui
|
|
AudioServiceBackground.sendCustomEvent({
|
|
'action': 'onRestore',
|
|
'queueSource': (queueSource??QueueSource()).toJson(),
|
|
'loopMode': LoopMode.values.indexOf(_loopMode)
|
|
});
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future onAddQueueItemAt(MediaItem mi, int index) async {
|
|
//-1 == play next
|
|
if (index == -1) index = _queueIndex + 1;
|
|
|
|
_queue.insert(index, mi);
|
|
await AudioServiceBackground.setQueue(_queue);
|
|
AudioSource _newSource = await _mediaItemToAudioSource(mi);
|
|
if (_newSource != null)
|
|
await _audioSource.insert(index,_newSource);
|
|
|
|
_saveQueue();
|
|
}
|
|
|
|
//Add at end of queue
|
|
@override
|
|
Future onAddQueueItem(MediaItem mi) async {
|
|
if (_queue.indexWhere((m) => m.id == mi.id) != -1)
|
|
return;
|
|
|
|
_queue.add(mi);
|
|
await AudioServiceBackground.setQueue(_queue);
|
|
AudioSource _newSource = await _mediaItemToAudioSource(mi);
|
|
if (_newSource != null)
|
|
await _audioSource.add(_newSource);
|
|
_saveQueue();
|
|
}
|
|
|
|
@override
|
|
Future onPlayFromMediaId(String mediaId) async {
|
|
|
|
//Android auto load tracks
|
|
if (mediaId.startsWith(AndroidAuto.prefix)) {
|
|
AudioServiceBackground.sendCustomEvent({
|
|
'action': 'tracksAndroidAuto',
|
|
'id': mediaId.replaceFirst(AndroidAuto.prefix, '')
|
|
});
|
|
return;
|
|
}
|
|
|
|
//Does the same thing
|
|
await this.onSkipToQueueItem(mediaId);
|
|
}
|
|
|
|
}
|
|
|
|
//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;
|
|
}
|
|
} |