0.4.0 - translations, download fallback, android auto, radio, infinite flow, bugfixes
This commit is contained in:
parent
a5381f0fed
commit
e984621eeb
88 changed files with 2911 additions and 379 deletions
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue