0.4.0 - translations, download fallback, android auto, radio, infinite flow, bugfixes

This commit is contained in:
exttex 2020-09-18 19:36:41 +02:00
parent a5381f0fed
commit e984621eeb
88 changed files with 2911 additions and 379 deletions

View file

@ -20,6 +20,7 @@ class DeezerAPI {
String token;
String userId;
String userName;
String favoritesPlaylistId;
String privateUrl = 'http://www.deezer.com/ajax/gw-light.php';
Map<String, String> headers = {
@ -95,6 +96,7 @@ class DeezerAPI {
} else {
this.token = data['results']['checkForm'];
this.userId = data['results']['USER']['USER_ID'].toString();
this.userName = data['results']['USER']['BLOG_NAME'];
this.favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID'];
return true;
}
@ -384,5 +386,20 @@ class DeezerAPI {
return data['results']['data'].map<Album>((a) => Album.fromPrivateJson(a)).toList();
}
Future<List> searchSuggestions(String query) async {
Map data = await callApi('search_getSuggestedQueries', params: {
'QUERY': query
});
return data['results']['SUGGESTION'].map((s) => s['QUERY']).toList();
}
//Get smart radio for artist id
Future<List<Track>> smartRadio(String artistId) async {
Map data = await callApi('smart.getSmartRadio', params: {
'art_id': int.parse(artistId)
});
return data['results']['data'].map<Track>((t) => Track.fromPrivateJson(t)).toList();
}
}

View file

@ -254,7 +254,10 @@ class Artist {
bool offline;
bool library;
Artist({this.id, this.name, this.albums, this.albumCount, this.topTracks, this.picture, this.fans, this.offline, this.library});
//TODO: NOT IN DB
bool radio;
Artist({this.id, this.name, this.albums, this.albumCount, this.topTracks, this.picture, this.fans, this.offline, this.library, this.radio});
String get fansString => NumberFormat.compact().format(fans);
@ -264,16 +267,23 @@ class Artist {
Map<dynamic, dynamic> albumsJson = const {},
Map<dynamic, dynamic> topJson = const {},
bool library = false
}) => Artist(
id: json['ART_ID'].toString(),
name: json['ART_NAME'],
fans: json['NB_FAN'],
picture: ImageDetails.fromPrivateString(json['ART_PICTURE'], type: 'artist'),
albumCount: albumsJson['total'],
albums: (albumsJson['data']??[]).map<Album>((dynamic data) => Album.fromPrivateJson(data)).toList(),
topTracks: (topJson['data']??[]).map<Track>((dynamic data) => Track.fromPrivateJson(data)).toList(),
library: library
);
}) {
//Get wether radio is available
bool _radio = false;
if (json['SMARTRADIO'] == true || json['SMARTRADIO'] == 1) _radio = true;
return Artist(
id: json['ART_ID'].toString(),
name: json['ART_NAME'],
fans: json['NB_FAN'],
picture: ImageDetails.fromPrivateString(json['ART_PICTURE'], type: 'artist'),
albumCount: albumsJson['total'],
albums: (albumsJson['data']??[]).map<Album>((dynamic data) => Album.fromPrivateJson(data)).toList(),
topTracks: (topJson['data']??[]).map<Track>((dynamic data) => Track.fromPrivateJson(data)).toList(),
library: library,
radio: _radio
);
}
Map<String, dynamic> toSQL({off = false}) => {
'id': id,
'name': name,

View file

@ -142,6 +142,7 @@ Artist _$ArtistFromJson(Map<String, dynamic> json) {
fans: json['fans'] as int,
offline: json['offline'] as bool,
library: json['library'] as bool,
radio: json['radio'] as bool,
);
}
@ -155,6 +156,7 @@ Map<String, dynamic> _$ArtistToJson(Artist instance) => <String, dynamic>{
'fans': instance.fans,
'offline': instance.offline,
'library': instance.library,
'radio': instance.radio,
};
Playlist _$PlaylistFromJson(Map<String, dynamic> json) {

View file

@ -122,14 +122,6 @@ class DownloadManager {
onDone: () async {
//On download finished
await db.rawUpdate('UPDATE downloads SET state = 1 WHERE trackId = ?', [queue[0].track.id]);
/*
if (queue[0].private) {
await db.rawUpdate('UPDATE downloads SET state = 1 WHERE trackId = ?', [queue[0].track.id]);
} else {
//Remove on db if public
await db.delete('downloads', where: 'trackId = ?', whereArgs: [queue[0].track.id]);
}
*/
queue.removeAt(0);
_download = null;
//Remove notification if no more downloads
@ -144,7 +136,11 @@ class DownloadManager {
//Catch download errors
_download = null;
_cancelNotifications = true;
//Cancellation error i guess
queue[0].state = DownloadState.NONE;
//Shift to end
queue.add(queue[0]);
queue.removeAt(0);
//Show error
await _showError();
});
//Show download progress notifications
@ -318,14 +314,12 @@ class DownloadManager {
track = await deezerAPI.track(track.id);
}
String url = track.getUrl(settings.getQualityInt(settings.offlineQuality));
if (!private) {
//Check permissions
if (!(await Permission.storage.request().isGranted)) {
return;
}
//If saving to external
url = track.getUrl(settings.getQualityInt(settings.downloadQuality));
//Save just extension to path, will be generated before download
path = 'mp3';
if (settings.downloadQuality == AudioQuality.FLAC) {
@ -339,7 +333,7 @@ class DownloadManager {
} catch (e) {}
}
Download download = Download(track: track, path: path, url: url, private: private);
Download download = Download(track: track, path: path, private: private);
//Database
Batch b = db.batch();
b.insert('downloads', download.toSQL());
@ -539,12 +533,17 @@ class Download {
//TODO: Check for internet before downloading
Map rawTrackPublic = {};
Map rawAlbumPublic = {};
if (!this.private && !(this.path.endsWith('.mp3') || this.path.endsWith('.flac'))) {
String ext = this.path;
//Get track details
Map _rawTrackData = await deezerAPI.callApi('song.getListData', params: {'sng_ids': [track.id]});
Map rawTrack = _rawTrackData['results']['data'][0];
this.track = Track.fromPrivateJson(rawTrack);
//RAW Public API call (for genre and other tags)
try {rawTrackPublic = await deezerAPI.callPublicApi('track/${this.track.id}');} catch (e) {rawTrackPublic = {};}
try {rawAlbumPublic = await deezerAPI.callPublicApi('album/${this.track.album.id}');} catch (e) {rawAlbumPublic = {};}
//Get path if public
RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]');
@ -620,6 +619,11 @@ class Download {
//Create file if doesnt exist
await downloadFile.create(recursive: true);
}
//Quality fallback
if (this.url == null)
await _fallback();
//Download
_cancel = CancelToken();
Response response = await dio.get(
@ -658,6 +662,10 @@ class Download {
//Tag
if (!private) {
//Tag track in native
String year;
if (rawTrackPublic['release_date'] != null && rawTrackPublic['release_date'].length >= 4)
year = rawTrackPublic['release_date'].substring(0, 4);
await platformChannel.invokeMethod('tagTrack', {
'path': path,
'title': track.title,
@ -665,7 +673,16 @@ class Download {
'artists': track.artistString,
'artist': track.artists[0].name,
'cover': _cover,
'trackNumber': track.trackNumber
'trackNumber': track.trackNumber,
'diskNumber': track.diskNumber,
'genres': ((rawAlbumPublic['genres']??{})['data']??[]).map((g) => g['name']).toList(),
'year': year,
'bpm': rawTrackPublic['bpm'],
'explicit': (track.explicit??false) ? "1":"0",
'label': rawAlbumPublic['label'],
'albumTracks': rawAlbumPublic['nb_tracks'],
'date': rawTrackPublic['release_date'],
'albumArtist': (rawAlbumPublic['artist']??{})['name']
});
//Rescan android library
await platformChannel.invokeMethod('rescanLibrary', {
@ -702,6 +719,36 @@ class Download {
return;
}
Future _fallback({fallback}) async {
//Get quality
AudioQuality quality = private ? settings.offlineQuality : settings.downloadQuality;
if (fallback == AudioQuality.MP3_320) quality = AudioQuality.MP3_128;
if (fallback == AudioQuality.FLAC) {
quality = AudioQuality.MP3_320;
if (this.path.toLowerCase().endsWith('flac'))
this.path = this.path.substring(0, this.path.length - 4) + 'mp3';
}
//No more fallback
if (quality == AudioQuality.MP3_128) {
url = track.getUrl(settings.getQualityInt(quality));
return;
}
//Check
int q = settings.getQualityInt(quality);
try {
Response res = await Dio().head(track.getUrl(q));
if (res.statusCode == 200 || res.statusCode == 206) {
this.url = track.getUrl(q);
return;
}
} catch (e) {}
//Fallback
return _fallback(fallback: quality);
}
//JSON
Map<String, dynamic> toSQL() => {
'trackId': track.id,

View file

@ -1,11 +1,13 @@
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/ui/details_screens.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 'definitions.dart';
import '../settings.dart';
@ -43,9 +45,21 @@ class PlayerHelper {
}
if (event['action'] == 'queueEnd') {
//If last song is played, load more queue
onQueueEnd();
this.queueSource = QueueSource.fromJson(event['queueSource']);
return;
}
//Android auto get screen
if (event['action'] == 'screenAndroidAuto') {
AndroidAuto androidAuto = AndroidAuto();
List<MediaItem> data = await androidAuto.getScreen(event['id']);
await AudioService.customAction('screenAndroidAuto', jsonEncode(data));
}
//Android auto play list
if (event['action'] == 'tracksAndroidAuto') {
AndroidAuto androidAuto = AndroidAuto();
await androidAuto.playItem(event['id']);
}
});
_playbackStateStreamSubscription = AudioService.playbackStateStream.listen((event) {
//Log song (if allowed)
@ -106,6 +120,30 @@ class PlayerHelper {
await AudioService.skipToQueueItem(trackId);
}
//Called when queue ends to load more tracks
Future onQueueEnd() async {
//Flow
if (queueSource == null) return;
if (queueSource.id == 'flow') {
List<Track> tracks = await deezerAPI.flow();
List<MediaItem> mi = tracks.map<MediaItem>((t) => t.toMediaItem()).toList();
await AudioService.addQueueItems(mi);
AudioService.skipToNext();
return;
}
//SmartRadio/Artist radio
if (queueSource.source == 'smartradio') {
List<Track> tracks = await deezerAPI.smartRadio(queueSource.id);
List<MediaItem> mi = tracks.map<MediaItem>((t) => t.toMediaItem()).toList();
await AudioService.addQueueItems(mi);
AudioService.skipToNext();
return;
}
print(queueSource.toJson());
}
//Play track from album
Future playFromAlbum(Album album, String trackId) async {
await playFromTrackList(album.tracks, trackId, QueueSource(
@ -144,7 +182,7 @@ class PlayerHelper {
if (stl.tracks == null || stl.tracks.length == 0) {
if (settings.offlineMode) {
Fluttertoast.showToast(
msg: "Offline mode, can't play flow/smart track lists.",
msg: "Offline mode, can't play flow or smart track lists.".i18n,
gravity: ToastGravity.BOTTOM,
toastLength: Toast.LENGTH_SHORT
);
@ -188,7 +226,6 @@ class AudioPlayerTask extends BackgroundAudioTask {
ConcatenatingAudioSource _audioSource;
AudioProcessingState _skipState;
bool _interrupted;
Seeker _seeker;
//Stream subscriptions
@ -200,10 +237,15 @@ class AudioPlayerTask extends BackgroundAudioTask {
QueueSource queueSource;
Duration _lastPosition;
Completer _androidAutoCallback;
MediaItem get mediaItem => _queue[_queueIndex];
@override
Future onStart(Map<String, dynamic> params) {
Future onStart(Map<String, dynamic> params) async {
final session = await AudioSession.instance;
session.configure(AudioSessionConfiguration.music());
//Update track index
_player.currentIndexStream.listen((index) {
@ -285,6 +327,20 @@ class AudioPlayerTask extends BackgroundAudioTask {
@override
Future<void> onSeekBackward(bool begin) async => _seekContinuously(begin, -1);
@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();
@ -430,46 +486,14 @@ class AudioPlayerTask extends BackgroundAudioTask {
if (name == 'load') await this._loadQueueFile();
//Shuffle
if (name == 'shuffle') await _player.setShuffleModeEnabled(args);
//Android auto callback
if (name == 'screenAndroidAuto' && _androidAutoCallback != null) {
_androidAutoCallback.complete(jsonDecode(args).map<MediaItem>((m) => MediaItem.fromJson(m)).toList());
}
return true;
}
//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;
}
}
@override
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();
@ -561,6 +585,16 @@ class AudioPlayerTask extends BackgroundAudioTask {
@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);
}

View file

@ -115,7 +115,7 @@ class SpotifyPlaylist {
factory SpotifyPlaylist.fromJson(Map json) => SpotifyPlaylist(
name: json['name'],
description: json['description'],
image: json['images'][0]['url'],
image: (json['images'].length > 0) ? json['images'][0]['url'] : null,
tracks: json['tracks']['items'].map<SpotifyTrack>((j) => SpotifyTrack.fromJson(j['track'])).toList()
);
}