Updated packages, rewrote player = gapless playback, faster loading

This commit is contained in:
exttex 2020-08-13 19:39:22 +02:00
parent 6f250df004
commit d4299f736f
92 changed files with 10270 additions and 1450 deletions

View file

@ -360,6 +360,18 @@ class DeezerAPI {
//Return playlistId
return data['results'].toString();
}
//Get part of discography
Future<List<Album>> discographyPage(String artistId, {int start = 0, int nb = 50}) async {
Map data = await callApi('album.getDiscography', params: {
'art_id': int.parse(artistId),
'discography_mode': 'all',
'nb': nb,
'start': start,
'nb_songs': 30
});
return data['results']['data'].map<Album>((a) => Album.fromPrivateJson(a)).toList();
}
}

View file

@ -80,6 +80,7 @@ class Track {
id: this.id,
extras: {
"playbackDetails": jsonEncode(this.playbackDetails),
"thumb": this.albumArt.thumb,
"lyrics": jsonEncode(this.lyrics.toJson()),
"albumId": this.album.id,
"artists": jsonEncode(this.artists.map<Map>((art) => art.toJson()).toList())
@ -102,7 +103,10 @@ class Track {
artists: artists,
album: album,
id: mi.id,
albumArt: ImageDetails(fullUrl: mi.artUri),
albumArt: ImageDetails(
fullUrl: mi.artUri,
thumbUrl: mi.extras['thumb']
),
duration: mi.duration,
playbackDetails: null, // So it gets updated from api
lyrics: Lyrics.fromJson(jsonDecode(((mi.extras??{})['lyrics'])??"{}"))
@ -116,7 +120,7 @@ class Track {
title = "${json['SNG_TITLE']} ${json['VERSION']}";
}
return Track(
id: json['SNG_ID'],
id: json['SNG_ID'].toString(),
title: title,
duration: Duration(seconds: int.parse(json['DURATION'])),
albumArt: ImageDetails.fromPrivateString(json['ALB_PICTURE']),
@ -180,7 +184,7 @@ class Album {
//JSON
factory Album.fromPrivateJson(Map<dynamic, dynamic> json, {Map<dynamic, dynamic> songsJson = const {}, bool library = false}) => Album(
id: json['ALB_ID'],
id: json['ALB_ID'].toString(),
title: json['ALB_TITLE'],
art: ImageDetails.fromPrivateString(json['ALB_PICTURE']),
artists: (json['ARTISTS']??[json]).map<Artist>((dynamic art) => Artist.fromPrivateJson(art)).toList(),
@ -240,7 +244,7 @@ class Artist {
Map<dynamic, dynamic> topJson = const {},
bool library = false
}) => Artist(
id: json['ART_ID'],
id: json['ART_ID'].toString(),
name: json['ART_NAME'],
fans: json['NB_FAN'],
picture: ImageDetails.fromPrivateString(json['ART_PICTURE'], type: 'artist'),
@ -299,7 +303,7 @@ class Playlist {
//JSON
factory Playlist.fromPrivateJson(Map<dynamic, dynamic> json, {Map<dynamic, dynamic> songsJson = const {}, bool library = false}) => Playlist(
id: json['PLAYLIST_ID'],
id: json['PLAYLIST_ID'].toString(),
title: json['TITLE'],
trackCount: json['NB_SONG']??songsJson['total'],
image: ImageDetails.fromPrivateString(json['PLAYLIST_PICTURE'], type: 'playlist'),

View file

@ -328,7 +328,8 @@ class DownloadManager {
List<Map> duplicate = await db.rawQuery('SELECT * FROM downloads WHERE trackId == ?', [track.id]);
if (duplicate.length != 0) return;
//Save art
await imagesDatabase.getImage(track.albumArt.full, permanent: true);
//await imagesDatabase.getImage(track.albumArt.full);
imagesDatabase.saveImage(track.albumArt.full);
//Save to db
b.insert('tracks', track.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
b.insert('albums', track.album.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore);

View file

@ -1,9 +1,6 @@
import 'package:audio_service/audio_service.dart';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/ui/cached_image.dart';
import 'package:just_audio/just_audio.dart';
import 'package:connectivity/connectivity.dart';
import 'package:path/path.dart' as p;
@ -23,7 +20,8 @@ class PlayerHelper {
StreamSubscription _customEventSubscription;
StreamSubscription _playbackStateStreamSubscription;
QueueSource queueSource;
RepeatType repeatType = RepeatType.NONE;
LoopMode repeatType = LoopMode.off;
bool shuffle = false;
//Find queue index by id
int get queueIndex => AudioService.queue.indexWhere((mi) => mi.id == AudioService.currentMediaItem?.id??'Random string so it returns -1');
@ -45,7 +43,6 @@ class PlayerHelper {
if (event['action'] == 'queueEnd') {
//If last song is played, load more queue
this.queueSource = QueueSource.fromJson(event['queueSource']);
print(queueSource.toJson());
return;
}
});
@ -74,20 +71,24 @@ class PlayerHelper {
);
}
Future toggleShuffle() async {
this.shuffle = !this.shuffle;
await AudioService.customAction('shuffle', this.shuffle);
}
//Repeat toggle
Future changeRepeat() async {
//Change to next repeat type
switch (repeatType) {
case RepeatType.NONE:
repeatType = RepeatType.LIST; break;
case RepeatType.LIST:
repeatType = RepeatType.TRACK; break;
case LoopMode.one:
repeatType = LoopMode.off; break;
case LoopMode.all:
repeatType = LoopMode.one; break;
default:
repeatType = RepeatType.NONE; break;
repeatType = LoopMode.all; break;
}
//Set repeat type
await AudioService.customAction("repeatType", RepeatType.values.indexOf(repeatType));
await AudioService.customAction("repeatType", LoopMode.values.indexOf(repeatType));
}
//Executed before exit
@ -101,7 +102,7 @@ class PlayerHelper {
await startService();
await settings.updateAudioServiceQuality();
await AudioService.updateQueue(queue);
await AudioService.playFromMediaId(trackId);
await AudioService.skipToQueueItem(trackId);
}
//Play track from album
@ -178,277 +179,229 @@ void backgroundTaskEntrypoint() async {
}
class AudioPlayerTask extends BackgroundAudioTask {
AudioPlayer _player = AudioPlayer();
AudioPlayer _audioPlayer = AudioPlayer();
//Queue
List<MediaItem> _queue = <MediaItem>[];
int _queueIndex = -1;
int _queueIndex = 0;
ConcatenatingAudioSource _audioSource;
bool _playing;
bool _interrupted;
AudioProcessingState _skipState;
Duration _lastPosition;
bool _interrupted;
Seeker _seeker;
ImagesDatabase imagesDB;
//Stream subscriptions
StreamSubscription _eventSub;
//Loaded from file/frontend
int mobileQuality;
int wifiQuality;
StreamSubscription _eventSub;
StreamSubscription _playerStateSub;
QueueSource queueSource;
int repeatType = 0;
Duration _lastPosition;
MediaItem get mediaItem => _queue[_queueIndex];
//Controls
final playControl = MediaControl(
androidIcon: 'drawable/ic_play_arrow',
label: 'Play',
action: MediaAction.play
);
final pauseControl = MediaControl(
androidIcon: 'drawable/ic_pause',
label: 'Pause',
action: MediaAction.pause
);
final stopControl = MediaControl(
androidIcon: 'drawable/ic_stop',
label: 'Stop',
action: MediaAction.stop
);
final nextControl = MediaControl(
androidIcon: 'drawable/ic_skip_next',
label: 'Next',
action: MediaAction.skipToNext
);
final previousControl = MediaControl(
androidIcon: 'drawable/ic_skip_previous',
label: 'Previous',
action: MediaAction.skipToPrevious
);
@override
Future onStart(Map<String, dynamic> params) async {
_playerStateSub = _audioPlayer.playbackStateStream
.where((state) => state == AudioPlaybackState.completed)
.listen((_event) {
if (_queue.length > _queueIndex + 1) {
onSkipToNext();
return;
} else {
//Repeat whole list (if enabled)
if (repeatType == 1) {
_skip(-_queueIndex);
return;
}
//Ask for more tracks in queue
AudioServiceBackground.sendCustomEvent({
'action': 'queueEnd',
'queueSource': (queueSource??QueueSource()).toJson()
});
if (_playing) _playing = false;
_setState(AudioProcessingState.none);
return;
Future onStart(Map<String, dynamic> params) {
//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) {
_broadcastState();
});
_player.processingStateStream.listen((state) {
switch(state) {
case ProcessingState.completed:
//Player ended, get more songs
AudioServiceBackground.sendCustomEvent({
'action': 'queueEnd',
'queueSource': (queueSource??QueueSource()).toJson()
});
break;
case ProcessingState.ready:
//Ready to play
_skipState = null;
break;
default:
break;
}
});
//Read audio player events
_eventSub = _audioPlayer.playbackEventStream.listen((event) {
AudioProcessingState bufferingState = event.buffering ? AudioProcessingState.buffering : null;
switch (event.state) {
case AudioPlaybackState.paused:
case AudioPlaybackState.playing:
_setState(bufferingState ?? AudioProcessingState.ready, pos: event.position);
break;
case AudioPlaybackState.connecting:
_setState(_skipState ?? AudioProcessingState.connecting, pos: event.position);
break;
default:
break;
}
});
//Initialize later
//await imagesDB.init();
//Load queue
AudioServiceBackground.setQueue(_queue);
AudioServiceBackground.sendCustomEvent({'action': 'onLoad'});
}
@override
Future onSkipToNext() async {
//If repeating allowed
if (repeatType == 2) {
await _skip(0);
return null;
}
_skip(1);
}
Future onSkipToQueueItem(String mediaId) async {
_lastPosition = null;
@override
Future onSkipToPrevious() => _skip(-1);
//Calculate new index
final newIndex = _queue.indexWhere((i) => i.id == mediaId);
if (newIndex == -1) return;
Future _skip(int offset) async {
int newPos = _queueIndex + offset;
//Out of bounds
if (newPos >= _queue.length || newPos < 0) return;
//First song
if (_playing == null) {
_playing = true;
} else if (_playing) {
await _audioPlayer.stop();
}
//Update position, album art source, queue source text
_queueIndex = newPos;
//Get uri
String uri = await _getTrackUri(mediaItem);
//Modify extras
Map<String, dynamic> extras = mediaItem.extras;
extras.addAll({"qualityString": await _getQualityString(uri, mediaItem.duration)});
_queue[_queueIndex] = mediaItem.copyWith(
artUri: await _getArtUri(mediaItem.artUri),
extras: extras
);
//Play
AudioServiceBackground.setMediaItem(mediaItem);
_skipState = offset > 0 ? AudioProcessingState.skippingToNext:AudioProcessingState.skippingToPrevious;
//Load
await _audioPlayer.setUrl(uri);
//Update buffering state
_skipState = newIndex > _queueIndex
? AudioProcessingState.skippingToNext
: AudioProcessingState.skippingToPrevious;
//Skip in player
await _player.seek(Duration.zero, index: newIndex);
_skipState = null;
await _saveQueue();
(_playing??false) ? onPlay() : _setState(AudioProcessingState.ready);
}
@override
void onPlay() async {
//Start playing preloaded queue
if (AudioServiceBackground.state.processingState == AudioProcessingState.none && _queue.length > 0) {
if (_queueIndex < 0 || _queueIndex == null) {
await this._skip(1);
} else {
await this._skip(0);
}
//Restore position from saved queue
if (_lastPosition != null) {
onSeekTo(_lastPosition);
_lastPosition = null;
}
return;
}
if (_skipState == null) {
_playing = true;
_audioPlayer.play();
}
}
@override
void onPause() {
if (_skipState == null && _playing) {
_playing = false;
_audioPlayer.pause();
}
}
@override
void onSeekTo(Duration pos) {
_audioPlayer.seek(pos);
}
@override
void onClick(MediaButton button) {
if (_playing) onPause();
onPlay();
}
@override
Future onUpdateQueue(List<MediaItem> q) async {
this._queue = q;
AudioServiceBackground.setQueue(_queue);
await _saveQueue();
Future onPlay() {
_player.play();
//Restore position on play
if (_lastPosition != null) {
onSeekTo(_lastPosition);
}
}
@override
void onPlayFromMediaId(String mediaId) async {
int pos = this._queue.indexWhere((mi) => mi.id == mediaId);
await _skip(pos - _queueIndex);
if (_playing == null || !_playing) onPlay();
}
Future onPause() => _player.pause();
@override
Future onFastForward() async {
await _seekRelative(fastForwardInterval);
}
Future onSeekTo(Duration pos) => _player.seek(pos);
@override
void onAddQueueItemAt(MediaItem mi, int index) {
_queue.insert(index, mi);
AudioServiceBackground.setQueue(_queue);
_saveQueue();
}
Future<void> onFastForward() => _seekRelative(fastForwardInterval);
@override
void onAddQueueItem(MediaItem mi) {
_queue.add(mi);
AudioServiceBackground.setQueue(_queue);
_saveQueue();
}
Future<void> onRewind() => _seekRelative(-rewindInterval);
@override
Future onRewind() async {
await _seekRelative(rewindInterval);
Future<void> onSeekForward(bool begin) async => _seekContinuously(begin, 1);
@override
Future<void> onSeekBackward(bool begin) async => _seekContinuously(begin, -1);
//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 = _audioPlayer.playbackEvent.position + offset;
Duration newPos = _player.position + offset;
//Out of bounds check
if (newPos < Duration.zero) newPos = Duration.zero;
if (newPos > mediaItem.duration) newPos = mediaItem.duration;
onSeekTo(_audioPlayer.playbackEvent.position + offset);
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,
//MediaControl.stop
],
systemActions: [
MediaAction.seekTo,
MediaAction.seekForward,
MediaAction.seekBackward
],
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.none:
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 onUpdateMediaItem(MediaItem mediaItem) async {
_queue[_queueIndex] = mediaItem;
Future onUpdateQueue(List<MediaItem> q) async {
//just_audio
_player.stop();
if (_audioSource != null) _audioSource.clear();
//audio_service
this._queue = q;
AudioServiceBackground.setQueue(_queue);
//Load
await _loadQueue();
await _player.seek(Duration.zero, index: 0);
}
//Load queue to just_audio
Future _loadQueue() async {
List<AudioSource> sources = [];
for(int i=0; i<_queue.length; i++) {
sources.add(await _mediaItemToAudioSource(_queue[i]));
}
_audioSource = ConcatenatingAudioSource(children: sources);
//Load in just_audio
try {
await _player.load(_audioSource);
} catch (e) {
//Error loading tracks
}
AudioServiceBackground.setMediaItem(mediaItem);
}
//Audio interruptions
@override
void onAudioFocusLost(AudioInterruption interruption) {
if (_playing) _interrupted = true;
switch (interruption) {
case AudioInterruption.pause:
case AudioInterruption.temporaryPause:
case AudioInterruption.unknownPause:
if (_playing) onPause();
break;
case AudioInterruption.temporaryDuck:
_audioPlayer.setVolume(0.5);
break;
Future<AudioSource> _mediaItemToAudioSource(MediaItem mi) async {
String url = await _getTrackUrl(mi);
if (url.startsWith('http')) return ProgressiveAudioSource(Uri.parse(url));
return AudioSource.uri(Uri.parse(url));
}
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;
}
//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;
String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}';
return url;
}
@override
void onAudioFocusGained(AudioInterruption interruption) {
switch (interruption) {
case AudioInterruption.temporaryPause:
if (!_playing && _interrupted) onPlay();
break;
case AudioInterruption.temporaryDuck:
_audioPlayer.setVolume(1.0);
break;
default:
break;
}
_interrupted = false;
}
@override
void onAudioBecomingNoisy() {
onPause();
}
//Custom actions
@override
Future onCustomAction(String name, dynamic args) async {
if (name == 'updateQuality') {
@ -457,228 +410,178 @@ class AudioPlayerTask extends BackgroundAudioTask {
this.wifiQuality = args['wifiQuality'];
this.mobileQuality = args['mobileQuality'];
}
if (name == 'saveQueue') {
await this._saveQueue();
}
//Load queue, called after start
if (name == 'load') {
await _loadQueue();
}
//Change queue source
if (name == 'queueSource') {
this.queueSource = QueueSource.fromJson(Map<String, dynamic>.from(args));
}
//Shuffle
if (name == 'shuffleQueue') {
MediaItem mi = mediaItem;
shuffle(this._queue);
_queueIndex = _queue.indexOf(mi);
AudioServiceBackground.setQueue(this._queue);
}
//Repeating
//Looping
if (name == 'repeatType') {
this.repeatType = args;
_player.setLoopMode(LoopMode.values[args]);
}
if (name == 'saveQueue') await this._saveQueue();
//Load queue after some initialization in frontend
if (name == 'load') await this._loadQueueFile();
//Shuffle
if (name == 'shuffle') await _player.setShuffleModeEnabled(args);
return true;
}
Future<String> _getArtUri(String url) async {
//Load from cache
if (url.startsWith('http')) {
//Prepare db
if (imagesDB == null) {
imagesDB = ImagesDatabase();
await imagesDB.init();
}
String path = await imagesDB.getImage(url);
return 'file://$path';
//Audio interruptions
@override
Future onAudioFocusLost(AudioInterruption interruption) {
if (_player.playing) _interrupted = true;
switch (interruption) {
case AudioInterruption.pause:
case AudioInterruption.temporaryPause:
case AudioInterruption.unknownPause:
if (_player.playing) onPause();
break;
case AudioInterruption.temporaryDuck:
_player.setVolume(0.5);
break;
}
//If file
if (url.startsWith('/')) return 'file://' + url;
return url;
}
Future<String> _getTrackUri(MediaItem mi, {int quality}) async {
String prefix = 'DEEZER|${mi.id}|';
//Check if song is available offline
String _offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
File f = File(p.join(_offlinePath, mi.id));
if (await f.exists()) return f.path;
//Get online url
Track t = Track(
id: mi.id,
playbackDetails: jsonDecode(mi.extras['playbackDetails']) //JSON Because of audio_service bug
);
//Check connection
if (quality == null) {
ConnectivityResult conn = await Connectivity().checkConnectivity();
quality = mobileQuality;
if (conn == ConnectivityResult.wifi) quality = wifiQuality;
}
String url = t.getUrl(quality);
//Quality fallback
Dio dio = Dio();
try {
await dio.head(url);
return prefix + url;
} catch (e) {
if (quality == 9) return _getTrackUri(mi, quality: 3);
if (quality == 3) return _getTrackUri(mi, quality: 1);
throw Exception('No available quality!');
}
}
Future<String> _getQualityString(String uri, Duration duration) async {
//Get url/path
String url = uri;
List<String> split = uri.split('|');
if (split.length >= 3) url = split[2];
int size;
String format;
String source;
//Local file
if (url.startsWith('/')) {
//Read first 4 bytes of file, get format
File f = File(url);
Stream<List<int>> reader = f.openRead(0, 4);
List<int> magic = await reader.first;
format = _magicToFormat(magic);
size = await f.length();
source = 'Offline';
}
//URL
if (url.startsWith('http')) {
Dio dio = Dio();
Response response = await dio.head(url);
size = int.parse(response.headers['Content-Length'][0]);
//Parse format
format = response.headers['Content-Type'][0];
if (format.trim() == 'audio/mpeg') format = 'MP3';
if (format.trim() == 'audio/flac') format = 'FLAC';
source = 'Stream';
}
//Calculate
return '$format ${_bitrateString(size, duration.inSeconds)} ($source)';
}
String _bitrateString(int size, int duration) {
int bitrate = ((size / 125) / duration).floor();
//Prettify
if (bitrate > 315 && bitrate < 325) return '320kbps';
if (bitrate > 125 && bitrate < 135) return '128kbps';
return '${bitrate}kbps';
}
//Magic number to string, source: https://en.wikipedia.org/wiki/List_of_file_signatures
String _magicToFormat(List<int> magic) {
Function eq = const ListEquality().equals;
if (eq(magic.sublist(0, 4), [0x66, 0x4c, 0x61, 0x43])) return 'FLAC';
//MP3 With ID3
if (eq(magic.sublist(0, 3), [0x49, 0x44, 0x33])) return 'MP3';
//MP3
List<int> m = magic.sublist(0, 2);
if (eq(m, [0xff, 0xfb]) ||eq(m, [0xff, 0xf3]) || eq(m, [0xff, 0xf2])) return 'MP3';
//Unknown
return 'UNK';
}
@override
void onTaskRemoved() async {
Future onAudioFocusGained(AudioInterruption interruption) {
switch (interruption) {
case AudioInterruption.temporaryPause:
if (!_player.playing && _interrupted) onPlay();
break;
case AudioInterruption.temporaryDuck:
_player.setVolume(1.0);
break;
default:
break;
}
_interrupted = false;
}
@override
Future onAudioBecomingNoisy() {
onPause();
}
@override
Future onTaskRemoved() async {
await onStop();
}
@override
Future onClose() async {
await onStop();
}
Future onStop() async {
_audioPlayer.stop();
if (_playerStateSub != null) _playerStateSub.cancel();
if (_eventSub != null) _eventSub.cancel();
await _saveQueue();
_player.stop();
if (_eventSub != null) _eventSub.cancel();
await super.onStop();
super.onStop();
}
@override
void onClose() async {
//await _saveQueue();
//Gets saved in onStop()
await onStop();
}
//Update state
void _setState(AudioProcessingState state, {Duration pos}) {
AudioServiceBackground.setState(
controls: _getControls(),
systemActions: (_playing == null) ? [] : [MediaAction.seekTo],
processingState: state ?? AudioServiceBackground.state.processingState,
playing: _playing ?? false,
position: pos ?? _audioPlayer.playbackEvent.position,
bufferedPosition: pos ?? _audioPlayer.playbackEvent.position,
speed: _audioPlayer.speed
);
}
List<MediaControl> _getControls() {
if (_playing == null || !_playing) {
//Paused / not-started
return [
previousControl,
playControl,
nextControl
];
}
//Playing
return [
previousControl,
pauseControl,
nextControl
];
}
//Get queue saved file path
//Get queue save file path
Future<String> _getQueuePath() async {
Directory dir = await getApplicationDocumentsDirectory();
return p.join(dir.path, 'offline.json');
return p.join(dir.path, 'playback.json');
}
//Export queue to JSON
Future _saveQueue() async {
print('save');
File f = File(await _getQueuePath());
await f.writeAsString(jsonEncode({
String path = await _getQueuePath();
File f = File(path);
//Create if doesnt 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': _audioPlayer.playbackEvent.position.inMilliseconds,
'position': _player.position.inMilliseconds,
'queueSource': (queueSource??QueueSource()).toJson(),
}));
};
await f.writeAsString(jsonEncode(data));
}
Future _loadQueue() async {
//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'] ?? -1;
this._queueIndex = json['index'] ?? 0;
this._lastPosition = Duration(milliseconds: json['position']??0);
this.queueSource = QueueSource.fromJson(json['queueSource']??{});
//Restore queue
if (_queue != null) {
AudioServiceBackground.setQueue(_queue);
await AudioServiceBackground.setQueue(_queue);
await _loadQueue();
AudioServiceBackground.setMediaItem(mediaItem);
//Update state to allow play button in notification
this._setState(AudioProcessingState.none, pos: _lastPosition);
}
//Send restored queue source to ui
AudioServiceBackground.sendCustomEvent({'action': 'onRestore', 'queueSource': (queueSource??QueueSource()).toJson()});
return true;
}
//Send restored queue source to ui
AudioServiceBackground.sendCustomEvent({
'action': 'onRestore',
'queueSource': (queueSource??QueueSource()).toJson()
});
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);
await _audioSource.insert(index, await _mediaItemToAudioSource(mi));
_saveQueue();
}
//Add at end of queue
@override
Future onAddQueueItem(MediaItem mi) async {
_queue.add(mi);
await AudioServiceBackground.setQueue(_queue);
await _audioSource.add(await _mediaItemToAudioSource(mi));
_saveQueue();
}
@override
Future onPlayFromMediaId(String mediaId) async {
//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;
}
}

View file

@ -27,7 +27,7 @@ void main() async {
//Initialize globals
settings = await Settings().loadSettings();
await imagesDatabase.init();
//await imagesDatabase.init();
await downloadManager.init();
runApp(FreezerApp());
@ -44,9 +44,6 @@ class _FreezerAppState extends State<FreezerApp> {
//Make update theme global
updateTheme = _updateTheme;
//Precache placeholder
precacheImage(imagesDatabase.placeholderThumb, context);
super.initState();
}

View file

@ -1,209 +1,66 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'dart:io';
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
ImagesDatabase imagesDatabase = ImagesDatabase();
class ImagesDatabase {
/*
images.db:
Table: images
Fields:
id - id
name - md5 hash of url. also filename
url - url
permanent - 0/1 - if image is cached or offline
!!! Using the wrappers so i don't have to rewrite most of the code, because of migration to cached network image
*/
Database db;
String imagesPath;
ImageProvider placeholderThumb = new AssetImage('assets/cover_thumb.jpg');
//Prepare database
Future init() async {
String dir = await getDatabasesPath();
String path = p.join(dir, 'images.db');
db = await openDatabase(
path,
version: 1,
singleInstance: false,
onCreate: (Database db, int version) async {
//Create table on db created
await db.execute('CREATE TABLE images (id INTEGER PRIMARY KEY, name TEXT, url TEXT, permanent INTEGER)');
}
);
//Prepare folders
imagesPath = p.join((await getApplicationDocumentsDirectory()).path, 'images/');
Directory imagesDir = Directory(imagesPath);
await imagesDir.create(recursive: true);
void saveImage(String url) {
CachedNetworkImageProvider(url);
}
String getPath(String name) {
return p.join(imagesPath, name);
Future<PaletteGenerator> getPaletteGenerator(String url) {
return PaletteGenerator.fromImageProvider(CachedNetworkImageProvider(url));
}
//Get image url/path, cache it
Future<String> getImage(String url, {bool permanent = false}) async {
//Already file
if (!url.startsWith('http')) {
url = url.replaceFirst('file://', '');
if (!permanent) return url;
//Update in db to permanent
String name = p.basenameWithoutExtension(url);
await db.rawUpdate('UPDATE images SET permanent == 1 WHERE name == ?', [name]);
}
//Filename = md5 hash
String hash = md5.convert(utf8.encode(url)).toString();
List<Map> results = await db.rawQuery('SELECT * FROM images WHERE name == ?', [hash]);
String path = getPath(hash);
if (results.length > 0) {
//Image in database
return path;
}
//Save image
Dio dio = Dio();
try {
await dio.download(url, path);
await db.insert('images', {'url': url, 'name': hash, 'permanent': permanent?1:0});
return path;
} catch (e) {
return null;
}
}
Future<PaletteGenerator> getPaletteGenerator(String url) async {
String path = await getImage(url);
//Get image provider
ImageProvider provider = placeholderThumb;
if (path != null) {
provider = FileImage(File(path));
}
PaletteGenerator paletteGenerator = await PaletteGenerator.fromImageProvider(provider);
return paletteGenerator;
}
//Get primary color from album art
Future<Color> getPrimaryColor(String url) async {
PaletteGenerator paletteGenerator = await getPaletteGenerator(url);
return paletteGenerator.colors.first;
}
//Check if is dark
Future<bool> isDark(String url) async {
PaletteGenerator paletteGenerator = await getPaletteGenerator(url);
return paletteGenerator.colors.first.computeLuminance() > 0.5 ? false : true;
}
}
class CachedImage extends StatefulWidget {
final String url;
final double width;
final double height;
final bool circular;
final bool fullThumb;
const CachedImage({Key key, this.url, this.height, this.width, this.circular = false}): super(key: key);
const CachedImage({Key key, this.url, this.height, this.width, this.circular = false, this.fullThumb = false}): super(key: key);
@override
_CachedImageState createState() => _CachedImageState();
}
class _CachedImageState extends State<CachedImage> {
ImageProvider _image = imagesDatabase.placeholderThumb;
double _opacity = 0.0;
bool _disposed = false;
String _prevUrl;
Future<ImageProvider> _getImage() async {
//Image already path
if (!widget.url.startsWith('http')) {
//Remove file://, if used in audio_service
if (widget.url.startsWith('/')) return FileImage(File(widget.url));
return FileImage(File(widget.url.replaceFirst('file://', '')));
}
//Load image from db
String path = await imagesDatabase.getImage(widget.url);
if (path == null) return imagesDatabase.placeholderThumb;
return FileImage(File(path));
}
//Load image and fade
void _load() async {
if (_prevUrl == widget.url) return;
ImageProvider image = await _getImage();
if (_disposed) return;
setState(() {
_image = image;
_opacity = 1.0;
});
_prevUrl = widget.url;
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
@override
void initState() {
_load();
super.initState();
}
@override
void didUpdateWidget(CachedImage oldWidget) {
_load();
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
widget.circular ?
CircleAvatar(
radius: (widget.width??widget.height),
backgroundImage: imagesDatabase.placeholderThumb,
):
Image(
image: imagesDatabase.placeholderThumb,
height: widget.height,
width: widget.width,
),
if (widget.circular) return ClipOval(
child: CachedImage(url: widget.url, height: widget.height, width: widget.width, circular: false)
);
AnimatedOpacity(
duration: Duration(milliseconds: 250),
opacity: _opacity,
child: widget.circular ?
CircleAvatar(
radius: (widget.width??widget.height),
backgroundImage: _image,
):
Image(
image: _image,
height: widget.height,
width: widget.width,
),
)
],
return CachedNetworkImage(
imageUrl: widget.url,
width: widget.width,
height: widget.height,
placeholder: (context, url) {
if (widget.fullThumb) return Image.asset('assets/cover.jpg', width: widget.width, height: widget.height,);
return Image.asset('assets/cover_thumb.jpg', width: widget.width, height: widget.height);
},
errorWidget: (context, url, error) => Image.asset('assets/cover_thumb.jpg', width: widget.width, height: widget.height),
);
}
}

View file

@ -394,7 +394,19 @@ class ArtistDetails extends StatelessWidget {
fontSize: 22.0
),
),
...List.generate(artist.albums.length, (i) {
...List.generate(artist.albums.length > 10 ? 11 : artist.albums.length + 1, (i) {
//Show discography
if (i == 10 || i == artist.albums.length) {
return ListTile(
title: Text('Show all albums'),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => DiscographyScreen(artist: artist,))
);
}
);
}
//Top albums
Album a = artist.albums[i];
return AlbumTile(
a,
@ -419,6 +431,103 @@ class ArtistDetails extends StatelessWidget {
}
}
class DiscographyScreen extends StatefulWidget {
Artist artist;
DiscographyScreen({@required this.artist, Key key}): super(key: key);
@override
_DiscographyScreenState createState() => _DiscographyScreenState();
}
class _DiscographyScreenState extends State<DiscographyScreen> {
Artist artist;
bool _loading = false;
bool _error = false;
ScrollController _scrollController = ScrollController();
Future _load() async {
if (artist.albums.length >= artist.albumCount || _loading) return;
setState(() => _loading = true);
//Fetch data
List<Album> data;
try {
data = await deezerAPI.discographyPage(artist.id, start: artist.albums.length);
} catch (e) {
setState(() {
_error = true;
_loading = false;
});
return;
}
//Save
setState(() {
artist.albums.addAll(data);
_loading = false;
});
}
@override
void initState() {
artist = widget.artist;
//Lazy loading scroll
_scrollController.addListener(() {
double off = _scrollController.position.maxScrollExtent * 0.90;
if (_scrollController.position.pixels > off) {
_load();
}
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Discography'),),
body: ListView.builder(
controller: _scrollController,
itemCount: artist.albums.length + 1,
itemBuilder: (context, i) {
//Loading
if (i == artist.albums.length) {
if (_loading)
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [CircularProgressIndicator()],
);
//Error
if (_error)
return ErrorScreen();
//Success
return Container(width: 0, height: 0,);
}
Album a = artist.albums[i];
return AlbumTile(
a,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => AlbumDetails(a))
);
},
onHold: () {
MenuSheet m = MenuSheet(context);
m.defaultAlbumMenu(a);
},
);
},
),
);
}
}
class PlaylistDetails extends StatefulWidget {

View file

@ -140,15 +140,8 @@ class MenuSheet {
title: Text('Play next'),
leading: Icon(Icons.playlist_play),
onTap: () async {
if (playerHelper.queueIndex == -1) {
//First track
await AudioService.addQueueItem(t.toMediaItem());
await AudioService.play();
} else {
//Normal
await AudioService.addQueueItemAt(
t.toMediaItem(), playerHelper.queueIndex + 1);
}
//-1 = next
await AudioService.addQueueItemAt(t.toMediaItem(), -1);
_close();
});

View file

@ -32,7 +32,7 @@ class PlayerBar extends StatelessWidget {
leading: CachedImage(
width: 50,
height: 50,
url: AudioService.currentMediaItem.artUri,
url: AudioService.currentMediaItem.extras['thumb'] ?? AudioService.currentMediaItem.artUri,
),
title: Text(
AudioService.currentMediaItem.displayTitle,

View file

@ -6,10 +6,12 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter_screenutil/screenutil.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/player.dart';
import 'package:freezer/settings.dart';
import 'package:freezer/ui/menu.dart';
import 'package:freezer/ui/settings_screen.dart';
import 'package:freezer/ui/tiles.dart';
import 'package:async/async.dart';
import 'package:just_audio/just_audio.dart';
import 'package:marquee/marquee.dart';
import 'cached_image.dart';
@ -84,9 +86,10 @@ class _PlayerScreenHorizontalState extends State<PlayerScreenHorizontal> {
children: <Widget>[
CachedImage(
url: AudioService.currentMediaItem.artUri,
fullThumb: true,
),
if (_lyrics) LyricsWidget(
artUri: AudioService.currentMediaItem.artUri,
artUri: AudioService.currentMediaItem.extras['thumb'],
trackId: AudioService.currentMediaItem.id,
lyrics: Track.fromMediaItem(AudioService.currentMediaItem).lyrics,
height: ScreenUtil().setWidth(500),
@ -188,7 +191,7 @@ class _PlayerScreenHorizontalState extends State<PlayerScreenHorizontal> {
MaterialPageRoute(builder: (context) => QualitySettings())
),
child: Text(
AudioService.currentMediaItem.extras['qualityString'],
AudioService.currentMediaItem.extras['qualityString'] ?? '',
style: TextStyle(fontSize: ScreenUtil().setSp(24)),
),
),
@ -242,9 +245,10 @@ class _PlayerScreenVerticalState extends State<PlayerScreenVertical> {
children: <Widget>[
CachedImage(
url: AudioService.currentMediaItem.artUri,
fullThumb: true,
),
if (_lyrics) LyricsWidget(
artUri: AudioService.currentMediaItem.artUri,
artUri: AudioService.currentMediaItem.extras['thumb'],
trackId: AudioService.currentMediaItem.id,
lyrics: Track.fromMediaItem(AudioService.currentMediaItem).lyrics,
height: ScreenUtil().setHeight(1050),
@ -322,7 +326,7 @@ class _PlayerScreenVerticalState extends State<PlayerScreenVertical> {
MaterialPageRoute(builder: (context) => QualitySettings())
),
child: Text(
AudioService.currentMediaItem.extras['qualityString'],
AudioService.currentMediaItem.extras['qualityString'] ?? '',
style: TextStyle(
fontSize: ScreenUtil().setSp(32),
),
@ -574,15 +578,15 @@ class _RepeatButtonState extends State<RepeatButton> {
Icon get icon {
switch (playerHelper.repeatType) {
case RepeatType.NONE:
case LoopMode.off:
return Icon(Icons.repeat, size: widget.size??_size);
case RepeatType.LIST:
case LoopMode.all:
return Icon(
Icons.repeat,
color: Theme.of(context).primaryColor,
size: widget.size??_size
);
case RepeatType.TRACK:
case LoopMode.one:
return Icon(
Icons.repeat_one,
color: Theme.of(context).primaryColor,
@ -708,6 +712,18 @@ class QueueScreen extends StatefulWidget {
}
class _QueueScreenState extends State<QueueScreen> {
//Get proper icon color by theme
Color get shuffleIconColor {
Color og = Theme.of(context).primaryColor;
if (og.computeLuminance() > 0.5) {
if (playerHelper.shuffle) return Theme.of(context).primaryColorLight;
return Colors.black;
}
if (playerHelper.shuffle) return Theme.of(context).primaryColorDark;
return Colors.white;
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -715,10 +731,13 @@ class _QueueScreenState extends State<QueueScreen> {
title: Text('Queue'),
actions: <Widget>[
IconButton(
icon: Icon(Icons.shuffle),
icon: Icon(
Icons.shuffle,
color: shuffleIconColor
),
onPressed: () async {
await AudioService.customAction('shuffleQueue');
setState(() => {});
await playerHelper.toggleShuffle();
setState(() {});
},
)
],

View file

@ -1,3 +1,4 @@
import 'package:connectivity/connectivity.dart';
import 'package:flutter/material.dart';
import 'package:freezer/api/download.dart';
import 'package:freezer/api/player.dart';
@ -7,7 +8,6 @@ import 'package:freezer/ui/menu.dart';
import 'tiles.dart';
import '../api/deezer.dart';
import '../api/definitions.dart';
import '../settings.dart';
import 'error.dart';
class SearchScreen extends StatefulWidget {
@ -18,7 +18,7 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen> {
String _query;
bool _offline = settings.offlineMode;
bool _offline = false;
void _submit(BuildContext context, {String query}) {
if (query != null) _query = query;
@ -27,6 +27,19 @@ class _SearchScreenState extends State<SearchScreen> {
);
}
@override
void initState() {
//Check for connectivity and enable offline mode
Connectivity().checkConnectivity().then((res) {
if (res == ConnectivityResult.none) setState(() {
_offline = true;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -59,11 +72,7 @@ class _SearchScreenState extends State<SearchScreen> {
leading: Switch(
value: _offline,
onChanged: (v) {
if (settings.offlineMode) {
setState(() => _offline = true);
} else {
setState(() => _offline = v);
}
setState(() => _offline = !_offline);
},
),
)