Initial commit
This commit is contained in:
commit
ed087bc583
123 changed files with 10390 additions and 0 deletions
356
lib/api/deezer.dart
Normal file
356
lib/api/deezer.dart
Normal file
|
@ -0,0 +1,356 @@
|
|||
import 'package:dio/dio.dart';
|
||||
import 'package:cookie_jar/cookie_jar.dart';
|
||||
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import '../settings.dart';
|
||||
import 'definitions.dart';
|
||||
|
||||
DeezerAPI deezerAPI = DeezerAPI();
|
||||
|
||||
class DeezerAPI {
|
||||
|
||||
String arl;
|
||||
|
||||
DeezerAPI({this.arl});
|
||||
|
||||
String token;
|
||||
String userId;
|
||||
String favoritesPlaylistId;
|
||||
String privateUrl = 'http://www.deezer.com/ajax/gw-light.php';
|
||||
Map<String, String> headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36",
|
||||
"Content-Language": '${settings.deezerLanguage??"en"}-${settings.deezerCountry??'US'}',
|
||||
"Cache-Control": "max-age=0",
|
||||
"Accept": "*/*",
|
||||
"Accept-Charset": "utf-8,ISO-8859-1;q=0.7,*;q=0.3",
|
||||
"Accept-Language": "${settings.deezerLanguage??"en"}-${settings.deezerCountry??'US'},${settings.deezerLanguage??"en"};q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Connection": "keep-alive"
|
||||
};
|
||||
|
||||
CookieJar _cookieJar = new CookieJar();
|
||||
|
||||
//Call private api
|
||||
Future<Map<dynamic, dynamic>> callApi(String method, {Map<dynamic, dynamic> params, String gatewayInput}) async {
|
||||
Dio dio = Dio();
|
||||
|
||||
//Add headers
|
||||
dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (RequestOptions options) {
|
||||
options.headers = this.headers;
|
||||
return options;
|
||||
}
|
||||
));
|
||||
//Add cookies
|
||||
List<Cookie> cookies = [Cookie('arl', this.arl)];
|
||||
_cookieJar.saveFromResponse(Uri.parse(this.privateUrl), cookies);
|
||||
dio.interceptors.add(CookieManager(_cookieJar));
|
||||
//Make request
|
||||
Response<dynamic> response = await dio.post(
|
||||
this.privateUrl,
|
||||
queryParameters: {
|
||||
'api_version': '1.0',
|
||||
'api_token': this.token,
|
||||
'input': '3',
|
||||
'method': method,
|
||||
|
||||
//Used for homepage
|
||||
if (gatewayInput != null)
|
||||
'gateway_input': gatewayInput
|
||||
},
|
||||
data: jsonEncode(params??{}),
|
||||
options: Options(responseType: ResponseType.json, sendTimeout: 7000, receiveTimeout: 7000)
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
//Authorize, bool = success
|
||||
Future<bool> authorize() async {
|
||||
try {
|
||||
Map<dynamic, dynamic> data = await callApi('deezer.getUserData');
|
||||
if (data['results']['USER']['USER_ID'] == 0) {
|
||||
return false;
|
||||
} else {
|
||||
this.token = data['results']['checkForm'];
|
||||
this.userId = data['results']['USER']['USER_ID'].toString();
|
||||
this.favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID'];
|
||||
return true;
|
||||
}
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
//Search
|
||||
Future<SearchResults> search(String query) async {
|
||||
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch', params: {
|
||||
'nb': 50,
|
||||
'query': query,
|
||||
'start': 0
|
||||
});
|
||||
return SearchResults.fromPrivateJson(data['results']);
|
||||
}
|
||||
|
||||
Future<Track> track(String id) async {
|
||||
Map<dynamic, dynamic> data = await callApi('song.getListData', params: {'sng_ids': [id]});
|
||||
return Track.fromPrivateJson(data['results']['data'][0]);
|
||||
}
|
||||
|
||||
//Get album details, tracks
|
||||
Future<Album> album(String id) async {
|
||||
Map<dynamic, dynamic> data = await callApi('deezer.pageAlbum', params: {
|
||||
'alb_id': id,
|
||||
'header': true,
|
||||
'lang': 'us'
|
||||
});
|
||||
return Album.fromPrivateJson(data['results']['DATA'], songsJson: data['results']['SONGS']);
|
||||
}
|
||||
|
||||
//Get artist details
|
||||
Future<Artist> artist(String id) async {
|
||||
Map<dynamic, dynamic> data = await callApi('deezer.pageArtist', params: {
|
||||
'art_id': id,
|
||||
'lang': 'us',
|
||||
});
|
||||
return Artist.fromPrivateJson(
|
||||
data['results']['DATA'],
|
||||
topJson: data['results']['TOP'],
|
||||
albumsJson: data['results']['ALBUMS']
|
||||
);
|
||||
}
|
||||
|
||||
//Get playlist tracks at offset
|
||||
Future<List<Track>> playlistTracksPage(String id, int start, {int nb = 50}) async {
|
||||
Map data = await callApi('deezer.pagePlaylist', params: {
|
||||
'playlist_id': id,
|
||||
'lang': 'us',
|
||||
'nb': nb,
|
||||
'tags': true,
|
||||
'start': start
|
||||
});
|
||||
return data['results']['SONGS']['data'].map<Track>((json) => Track.fromPrivateJson(json)).toList();
|
||||
}
|
||||
|
||||
//Get playlist details
|
||||
Future<Playlist> playlist(String id, {int nb = 100}) async {
|
||||
Map<dynamic, dynamic> data = await callApi('deezer.pagePlaylist', params: {
|
||||
'playlist_id': id,
|
||||
'lang': 'us',
|
||||
'nb': nb,
|
||||
'tags': true,
|
||||
'start': 0
|
||||
});
|
||||
return Playlist.fromPrivateJson(data['results']['DATA'], songsJson: data['results']['SONGS']);
|
||||
}
|
||||
|
||||
//Get playlist with all tracks
|
||||
Future<Playlist> fullPlaylist(String id) async {
|
||||
Playlist p = await playlist(id, nb: 200);
|
||||
for (int i=200; i<p.trackCount; i++) {
|
||||
//Get another page of tracks
|
||||
List<Track> tracks = await playlistTracksPage(id, i, nb: 200);
|
||||
p.tracks.addAll(tracks);
|
||||
i += 200;
|
||||
continue;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
//Add track to favorites
|
||||
Future addFavoriteTrack(String id) async {
|
||||
await callApi('favorite_song.add', params: {'SNG_ID': id});
|
||||
}
|
||||
|
||||
//Add album to favorites/library
|
||||
Future addFavoriteAlbum(String id) async {
|
||||
await callApi('album.addFavorite', params: {'ALB_ID': id});
|
||||
}
|
||||
|
||||
//Add artist to favorites/library
|
||||
Future addFavoriteArtist(String id) async {
|
||||
await callApi('artist.addFavorite', params: {'ART_ID': id});
|
||||
}
|
||||
|
||||
//Remove artist from favorites/library
|
||||
Future removeArtist(String id) async {
|
||||
await callApi('artist.deleteFavorite', params: {'ART_ID': id});
|
||||
}
|
||||
|
||||
//Add tracks to playlist
|
||||
Future addToPlaylist(String trackId, String playlistId, {int offset = -1}) async {
|
||||
await callApi('playlist.addSongs', params: {
|
||||
'offset': offset,
|
||||
'playlist_id': playlistId,
|
||||
'songs': [[trackId, 0]]
|
||||
});
|
||||
}
|
||||
|
||||
//Remove track from playlist
|
||||
Future removeFromPlaylist(String trackId, String playlistId) async {
|
||||
await callApi('playlist.deleteSongs', params: {
|
||||
'playlist_id': playlistId,
|
||||
'songs': [[trackId, 0]]
|
||||
});
|
||||
}
|
||||
|
||||
//Get users playlists
|
||||
Future<List<Playlist>> getPlaylists() async {
|
||||
Map data = await callApi('deezer.pageProfile', params: {
|
||||
'nb': 100,
|
||||
'tab': 'playlists',
|
||||
'user_id': this.userId
|
||||
});
|
||||
return data['results']['TAB']['playlists']['data'].map<Playlist>((json) => Playlist.fromPrivateJson(json, library: true)).toList();
|
||||
}
|
||||
|
||||
//Get favorite albums
|
||||
Future<List<Album>> getAlbums() async {
|
||||
Map data = await callApi('deezer.pageProfile', params: {
|
||||
'nb': 50,
|
||||
'tab': 'albums',
|
||||
'user_id': this.userId
|
||||
});
|
||||
List albumList = data['results']['TAB']['albums']['data'];
|
||||
List<Album> albums = albumList.map<Album>((json) => Album.fromPrivateJson(json, library: true)).toList();
|
||||
return albums;
|
||||
}
|
||||
|
||||
//Remove album from library
|
||||
Future removeAlbum(String id) async {
|
||||
await callApi('album.deleteFavorite', params: {
|
||||
'ALB_ID': id
|
||||
});
|
||||
}
|
||||
|
||||
//Remove track from favorites
|
||||
Future removeFavorite(String id) async {
|
||||
await callApi('favorite_song.remove', params: {
|
||||
'SNG_ID': id
|
||||
});
|
||||
}
|
||||
|
||||
//Get favorite artists
|
||||
Future<List<Artist>> getArtists() async {
|
||||
Map data = await callApi('deezer.pageProfile', params: {
|
||||
'nb': 40,
|
||||
'tab': 'artists',
|
||||
'user_id': this.userId
|
||||
});
|
||||
return data['results']['TAB']['artists']['data'].map<Artist>((json) => Artist.fromPrivateJson(json, library: true)).toList();
|
||||
}
|
||||
|
||||
//Get lyrics by track id
|
||||
Future<Lyrics> lyrics(String trackId) async {
|
||||
Map data = await callApi('song.getLyrics', params: {
|
||||
'sng_id': trackId
|
||||
});
|
||||
if (data['error'] != null && data['error'].length > 0) return Lyrics().error;
|
||||
return Lyrics.fromPrivateJson(data['results']);
|
||||
}
|
||||
|
||||
Future<SmartTrackList> smartTrackList(String id) async {
|
||||
Map data = await callApi('deezer.pageSmartTracklist', params: {
|
||||
'smarttracklist_id': id
|
||||
});
|
||||
return SmartTrackList.fromPrivateJson(data['results']['DATA'], songsJson: data['results']['SONGS']);
|
||||
}
|
||||
|
||||
Future<List<Track>> flow() async {
|
||||
Map data = await callApi('radio.getUserRadio', params: {
|
||||
'user_id': userId
|
||||
});
|
||||
return data['results']['data'].map<Track>((json) => Track.fromPrivateJson(json)).toList();
|
||||
}
|
||||
|
||||
//Get homepage/music library from deezer
|
||||
Future<HomePage> homePage() async {
|
||||
List grid = ['album', 'artist', 'channel', 'flow', 'playlist', 'radio', 'show', 'smarttracklist', 'track', 'user'];
|
||||
Map data = await callApi('page.get', gatewayInput: jsonEncode({
|
||||
"PAGE": "home",
|
||||
"VERSION": "2.3",
|
||||
"SUPPORT": {
|
||||
/*
|
||||
"deeplink-list": ["deeplink"],
|
||||
"list": ["episode"],
|
||||
"grid-preview-one": grid,
|
||||
"grid-preview-two": grid,
|
||||
"slideshow": grid,
|
||||
"message": ["call_onboarding"],
|
||||
*/
|
||||
"grid": grid,
|
||||
"horizontal-grid": grid,
|
||||
"item-highlight": ["radio"],
|
||||
"large-card": ["album", "playlist", "show", "video-link"],
|
||||
"ads": [] //Nope
|
||||
},
|
||||
"LANG": "us",
|
||||
"OPTIONS": []
|
||||
}));
|
||||
return HomePage.fromPrivateJson(data['results']);
|
||||
}
|
||||
|
||||
//Log song listen to deezer
|
||||
Future logListen(String trackId) async {
|
||||
await callApi('log.listen', params: {'next_media': {'media': {'id': trackId, 'type': 'song'}}});
|
||||
}
|
||||
|
||||
Future<HomePage> getChannel(String target) async {
|
||||
List grid = ['album', 'artist', 'channel', 'flow', 'playlist', 'radio', 'show', 'smarttracklist', 'track', 'user'];
|
||||
Map data = await callApi('page.get', gatewayInput: jsonEncode({
|
||||
'PAGE': target,
|
||||
"VERSION": "2.3",
|
||||
"SUPPORT": {
|
||||
/*
|
||||
"deeplink-list": ["deeplink"],
|
||||
"list": ["episode"],
|
||||
"grid-preview-one": grid,
|
||||
"grid-preview-two": grid,
|
||||
"slideshow": grid,
|
||||
"message": ["call_onboarding"],
|
||||
*/
|
||||
"grid": grid,
|
||||
"horizontal-grid": grid,
|
||||
"item-highlight": ["radio"],
|
||||
"large-card": ["album", "playlist", "show", "video-link"],
|
||||
"ads": [] //Nope
|
||||
},
|
||||
"LANG": "us",
|
||||
"OPTIONS": []
|
||||
}));
|
||||
return HomePage.fromPrivateJson(data['results']);
|
||||
}
|
||||
|
||||
//Add playlist to library
|
||||
Future addPlaylist(String id) async {
|
||||
await callApi('playlist.addFavorite', params: {
|
||||
'parent_playlist_id': int.parse(id)
|
||||
});
|
||||
}
|
||||
//Remove playlist from library
|
||||
Future removePlaylist(String id) async {
|
||||
await callApi('playlist.deleteFavorite', params: {
|
||||
'playlist_id': int.parse(id)
|
||||
});
|
||||
}
|
||||
//Delete playlist
|
||||
Future deletePlaylist(String id) async {
|
||||
await callApi('playlist.delete', params: {
|
||||
'playlist_id': id
|
||||
});
|
||||
}
|
||||
|
||||
//Create playlist
|
||||
//Status 1 - private, 2 - collaborative
|
||||
Future createPlaylist(String title, {String description = "", int status = 1, List<String> trackIds = const []}) async {
|
||||
Map data = await callApi('playlist.create', params: {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'songs': trackIds.map<List>((id) => [int.parse(id), trackIds.indexOf(id)]).toList(),
|
||||
'status': status
|
||||
});
|
||||
//Return playlistId
|
||||
return data['results'];
|
||||
}
|
||||
|
||||
}
|
||||
|
676
lib/api/definitions.dart
Normal file
676
lib/api/definitions.dart
Normal file
|
@ -0,0 +1,676 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pointycastle/api.dart';
|
||||
import 'package:pointycastle/block/aes_fast.dart';
|
||||
import 'package:pointycastle/block/modes/ecb.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
|
||||
part 'definitions.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class Track {
|
||||
|
||||
String id;
|
||||
String title;
|
||||
Album album;
|
||||
List<Artist> artists;
|
||||
Duration duration;
|
||||
ImageDetails albumArt;
|
||||
int trackNumber;
|
||||
bool offline;
|
||||
Lyrics lyrics;
|
||||
bool favorite;
|
||||
|
||||
List<dynamic> playbackDetails;
|
||||
|
||||
Track({this.id, this.title, this.duration, this.album, this.playbackDetails, this.albumArt,
|
||||
this.artists, this.trackNumber, this.offline, this.lyrics, this.favorite});
|
||||
|
||||
String get artistString => artists.map<String>((art) => art.name).join(', ');
|
||||
String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
|
||||
|
||||
String getUrl(int quality) {
|
||||
var md5 = crypto.md5;
|
||||
int magic = 164;
|
||||
List<int> _s1 = [
|
||||
...utf8.encode(playbackDetails[0]),
|
||||
magic,
|
||||
...utf8.encode(quality.toString()),
|
||||
magic,
|
||||
...utf8.encode(id),
|
||||
magic,
|
||||
...utf8.encode(playbackDetails[1])
|
||||
];
|
||||
List<int> _s2 = [
|
||||
...utf8.encode(HEX.encode(md5.convert(_s1).bytes)),
|
||||
magic,
|
||||
..._s1,
|
||||
magic
|
||||
];
|
||||
while(_s2.length%16 > 0) _s2.add(46);
|
||||
String _s3 = '';
|
||||
BlockCipher cipher = ECBBlockCipher(AESFastEngine());
|
||||
cipher.init(true, KeyParameter(Uint8List.fromList('jo6aey6haid2Teih'.codeUnits)));
|
||||
for (int i=0; i<_s2.length/16; i++) {
|
||||
_s3 += HEX.encode(cipher.process(Uint8List.fromList(_s2.sublist(i*16, i*16+16))));
|
||||
}
|
||||
return 'https://e-cdns-proxy-${playbackDetails[0][0]}.dzcdn.net/mobile/1/$_s3';
|
||||
}
|
||||
|
||||
//MediaItem
|
||||
MediaItem toMediaItem() => MediaItem(
|
||||
title: this.title,
|
||||
album: this.album.title,
|
||||
artist: this.artists[0].name,
|
||||
displayTitle: this.title,
|
||||
displaySubtitle: this.artistString,
|
||||
displayDescription: this.album.title,
|
||||
artUri: this.albumArt.full,
|
||||
duration: this.duration,
|
||||
id: this.id,
|
||||
extras: {
|
||||
"playbackDetails": jsonEncode(this.playbackDetails),
|
||||
"lyrics": jsonEncode(this.lyrics.toJson()),
|
||||
"albumId": this.album.id,
|
||||
"artists": jsonEncode(this.artists.map<Map>((art) => art.toJson()).toList())
|
||||
}
|
||||
);
|
||||
|
||||
factory Track.fromMediaItem(MediaItem mi) {
|
||||
//Load album and artists.
|
||||
//It is stored separately, to save id and other metadata
|
||||
Album album = Album(title: mi.album);
|
||||
List<Artist> artists = [Artist(name: mi.displaySubtitle??mi.artist)];
|
||||
if (mi.extras != null) {
|
||||
album.id = mi.extras['albumId'];
|
||||
if (mi.extras['artists'] != null) {
|
||||
artists = jsonDecode(mi.extras['artists']).map<Artist>((j) => Artist.fromJson(j)).toList();
|
||||
}
|
||||
}
|
||||
return Track(
|
||||
title: mi.title??mi.displayTitle,
|
||||
artists: artists,
|
||||
album: album,
|
||||
id: mi.id,
|
||||
albumArt: ImageDetails(fullUrl: mi.artUri),
|
||||
duration: mi.duration,
|
||||
playbackDetails: null, // So it gets updated from api
|
||||
lyrics: Lyrics.fromJson(jsonDecode(((mi.extras??{})['lyrics'])??"{}"))
|
||||
);
|
||||
}
|
||||
|
||||
//JSON
|
||||
factory Track.fromPrivateJson(Map<dynamic, dynamic> json, {bool favorite = false}) {
|
||||
String title = json['SNG_TITLE'];
|
||||
if (json['VERSION'] != null && json['VERSION'] != '') {
|
||||
title = "${json['SNG_TITLE']} ${json['VERSION']}";
|
||||
}
|
||||
return Track(
|
||||
id: json['SNG_ID'],
|
||||
title: title,
|
||||
duration: Duration(seconds: int.parse(json['DURATION'])),
|
||||
albumArt: ImageDetails.fromPrivateString(json['ALB_PICTURE']),
|
||||
album: Album.fromPrivateJson(json),
|
||||
artists: (json['ARTISTS']??[json]).map<Artist>((dynamic art) =>
|
||||
Artist.fromPrivateJson(art)).toList(),
|
||||
trackNumber: int.parse((json['TRACK_NUMBER']??'0').toString()),
|
||||
playbackDetails: [json['MD5_ORIGIN'], json['MEDIA_VERSION']],
|
||||
lyrics: Lyrics(id: json['LYRICS_ID'].toString()),
|
||||
favorite: favorite
|
||||
);
|
||||
}
|
||||
Map<String, dynamic> toSQL({off = false}) => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'album': album.id,
|
||||
'artists': artists.map<String>((dynamic a) => a.id).join(','),
|
||||
'duration': duration.inSeconds,
|
||||
'albumArt': albumArt.full,
|
||||
'trackNumber': trackNumber,
|
||||
'offline': off?1:0,
|
||||
'lyrics': jsonEncode(lyrics.toJson()),
|
||||
'favorite': (favorite??0)?1:0
|
||||
};
|
||||
factory Track.fromSQL(Map<String, dynamic> data) => Track(
|
||||
id: data['trackId']??data['id'], //If loading from downloads table
|
||||
title: data['title'],
|
||||
album: Album(id: data['album']),
|
||||
duration: Duration(seconds: data['duration']),
|
||||
albumArt: ImageDetails(fullUrl: data['albumArt']),
|
||||
trackNumber: data['trackNumber'],
|
||||
artists: List<Artist>.generate(data['artists'].split(',').length, (i) => Artist(
|
||||
id: data['artists'].split(',')[i]
|
||||
)),
|
||||
offline: (data['offline'] == 1) ? true:false,
|
||||
lyrics: Lyrics.fromJson(jsonDecode(data['lyrics'])),
|
||||
favorite: (data['favorite'] == 1) ? true:false
|
||||
);
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Album {
|
||||
String id;
|
||||
String title;
|
||||
List<Artist> artists;
|
||||
List<Track> tracks;
|
||||
ImageDetails art;
|
||||
int fans;
|
||||
bool offline; //If the album is offline, or just saved in db as metadata
|
||||
bool library;
|
||||
|
||||
Album({this.id, this.title, this.art, this.artists, this.tracks, this.fans, this.offline, this.library});
|
||||
|
||||
String get artistString => artists.map<String>((art) => art.name).join(', ');
|
||||
Duration get duration => Duration(seconds: tracks.fold(0, (v, t) => v += t.duration.inSeconds));
|
||||
String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
|
||||
String get fansString => NumberFormat.compact().format(fans);
|
||||
|
||||
//JSON
|
||||
factory Album.fromPrivateJson(Map<dynamic, dynamic> json, {Map<dynamic, dynamic> songsJson = const {}, bool library = false}) => Album(
|
||||
id: json['ALB_ID'],
|
||||
title: json['ALB_TITLE'],
|
||||
art: ImageDetails.fromPrivateString(json['ALB_PICTURE']),
|
||||
artists: (json['ARTISTS']??[json]).map<Artist>((dynamic art) => Artist.fromPrivateJson(art)).toList(),
|
||||
tracks: (songsJson['data']??[]).map<Track>((dynamic track) => Track.fromPrivateJson(track)).toList(),
|
||||
fans: json['NB_FAN'],
|
||||
library: library
|
||||
);
|
||||
Map<String, dynamic> toSQL({off = false}) => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'artists': artists.map<String>((dynamic a) => a.id).join(','),
|
||||
'tracks': tracks.map<String>((dynamic t) => t.id).join(','),
|
||||
'art': art.full,
|
||||
'fans': fans,
|
||||
'offline': off?1:0,
|
||||
'library': (library??false)?1:0
|
||||
};
|
||||
factory Album.fromSQL(Map<String, dynamic> data) => Album(
|
||||
id: data['id'],
|
||||
title: data['title'],
|
||||
artists: List<Artist>.generate(data['artists'].split(',').length, (i) => Artist(
|
||||
id: data['artists'].split(',')[i]
|
||||
)),
|
||||
tracks: List<Track>.generate(data['tracks'].split(',').length, (i) => Track(
|
||||
id: data['tracks'].split(',')[i]
|
||||
)),
|
||||
art: ImageDetails(fullUrl: data['art']),
|
||||
fans: data['fans'],
|
||||
offline: (data['offline'] == 1) ? true:false,
|
||||
library: (data['library'] == 1) ? true:false
|
||||
);
|
||||
|
||||
factory Album.fromJson(Map<String, dynamic> json) => _$AlbumFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$AlbumToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Artist {
|
||||
String id;
|
||||
String name;
|
||||
List<Album> albums;
|
||||
int albumCount;
|
||||
List<Track> topTracks;
|
||||
ImageDetails picture;
|
||||
int fans;
|
||||
bool offline;
|
||||
bool library;
|
||||
|
||||
Artist({this.id, this.name, this.albums, this.albumCount, this.topTracks, this.picture, this.fans, this.offline, this.library});
|
||||
|
||||
String get fansString => NumberFormat.compact().format(fans);
|
||||
|
||||
//JSON
|
||||
factory Artist.fromPrivateJson(
|
||||
Map<dynamic, dynamic> json, {
|
||||
Map<dynamic, dynamic> albumsJson = const {},
|
||||
Map<dynamic, dynamic> topJson = const {},
|
||||
bool library = false
|
||||
}) => Artist(
|
||||
id: json['ART_ID'],
|
||||
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
|
||||
);
|
||||
Map<String, dynamic> toSQL({off = false}) => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'albums': albums.map<String>((dynamic a) => a.id).join(','),
|
||||
'topTracks': topTracks.map<String>((dynamic t) => t.id).join(','),
|
||||
'picture': picture.full,
|
||||
'fans': fans,
|
||||
'albumCount': this.albumCount??(this.albums??[]).length,
|
||||
'offline': off?1:0,
|
||||
'library': (library??false)?1:0
|
||||
};
|
||||
factory Artist.fromSQL(Map<String, dynamic> data) => Artist(
|
||||
id: data['id'],
|
||||
name: data['name'],
|
||||
topTracks: List<Track>.generate(data['topTracks'].split(',').length, (i) => Track(
|
||||
id: data['topTracks'].split(',')[i]
|
||||
)),
|
||||
albums: List<Album>.generate(data['albums'].split(',').length, (i) => Album(
|
||||
id: data['albums'].split(',')[i]
|
||||
)),
|
||||
albumCount: data['albumCount'],
|
||||
picture: ImageDetails(fullUrl: data['picture']),
|
||||
fans: data['fans'],
|
||||
offline: (data['offline'] == 1)?true:false,
|
||||
library: (data['library'] == 1)?true:false
|
||||
);
|
||||
|
||||
factory Artist.fromJson(Map<String, dynamic> json) => _$ArtistFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$ArtistToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Playlist {
|
||||
String id;
|
||||
String title;
|
||||
List<Track> tracks;
|
||||
ImageDetails image;
|
||||
Duration duration;
|
||||
int trackCount;
|
||||
User user;
|
||||
int fans;
|
||||
bool library;
|
||||
String description;
|
||||
|
||||
Playlist({this.id, this.title, this.tracks, this.image, this.trackCount, this.duration, this.user, this.fans, this.library, this.description});
|
||||
|
||||
String get durationString => "${duration.inHours}:${duration.inMinutes.remainder(60).toString().padLeft(2, '0')}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
|
||||
|
||||
//JSON
|
||||
factory Playlist.fromPrivateJson(Map<dynamic, dynamic> json, {Map<dynamic, dynamic> songsJson = const {}, bool library = false}) => Playlist(
|
||||
id: json['PLAYLIST_ID'],
|
||||
title: json['TITLE'],
|
||||
trackCount: json['NB_SONG']??songsJson['total'],
|
||||
image: ImageDetails.fromPrivateString(json['PLAYLIST_PICTURE'], type: 'playlist'),
|
||||
fans: json['NB_FAN'],
|
||||
duration: Duration(seconds: json['DURATION']??0),
|
||||
description: json['DESCRIPTION'],
|
||||
user: User(
|
||||
id: json['PARENT_USER_ID'],
|
||||
name: json['PARENT_USERNAME']??'',
|
||||
picture: ImageDetails.fromPrivateString(json['PARENT_USER_PICTURE'], type: 'user')
|
||||
),
|
||||
tracks: (songsJson['data']??[]).map<Track>((dynamic data) => Track.fromPrivateJson(data)).toList(),
|
||||
library: library
|
||||
);
|
||||
Map<String, dynamic> toSQL() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'tracks': tracks.map<String>((dynamic t) => t.id).join(','),
|
||||
'image': image.full,
|
||||
'duration': duration.inSeconds,
|
||||
'userId': user.id,
|
||||
'userName': user.name,
|
||||
'fans': fans,
|
||||
'description': description,
|
||||
'library': (library??false)?1:0
|
||||
};
|
||||
factory Playlist.fromSQL(data) => Playlist(
|
||||
id: data['id'],
|
||||
title: data['title'],
|
||||
description: data['description'],
|
||||
tracks: List<Track>.generate(data['tracks'].split(',').length, (i) => Track(
|
||||
id: data['tracks'].split(',')[i]
|
||||
)),
|
||||
image: ImageDetails(fullUrl: data['image']),
|
||||
duration: Duration(seconds: data['duration']),
|
||||
user: User(
|
||||
id: data['userId'],
|
||||
name: data['userName']
|
||||
),
|
||||
fans: data['fans'],
|
||||
library: (data['library'] == 1)?true:false
|
||||
);
|
||||
|
||||
factory Playlist.fromJson(Map<String, dynamic> json) => _$PlaylistFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$PlaylistToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class User {
|
||||
String id;
|
||||
String name;
|
||||
ImageDetails picture;
|
||||
|
||||
User({this.id, this.name, this.picture});
|
||||
|
||||
//Mostly handled by playlist
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$UserToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ImageDetails {
|
||||
String fullUrl;
|
||||
String thumbUrl;
|
||||
|
||||
ImageDetails({this.fullUrl, this.thumbUrl});
|
||||
|
||||
//Get full/thumb with fallback
|
||||
String get full => fullUrl??thumbUrl;
|
||||
String get thumb => thumbUrl??fullUrl;
|
||||
|
||||
//JSON
|
||||
factory ImageDetails.fromPrivateString(String art, {String type='cover'}) => ImageDetails(
|
||||
fullUrl: 'https://e-cdns-images.dzcdn.net/images/$type/$art/1400x1400-000000-80-0-0.jpg',
|
||||
thumbUrl: 'https://e-cdns-images.dzcdn.net/images/$type/$art/180x180-000000-80-0-0.jpg'
|
||||
);
|
||||
factory ImageDetails.fromPrivateJson(Map<dynamic, dynamic> json) => ImageDetails.fromPrivateString(
|
||||
json['MD5'].split('-').first,
|
||||
type: json['TYPE']
|
||||
);
|
||||
|
||||
factory ImageDetails.fromJson(Map<String, dynamic> json) => _$ImageDetailsFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$ImageDetailsToJson(this);
|
||||
}
|
||||
|
||||
class SearchResults {
|
||||
List<Track> tracks;
|
||||
List<Album> albums;
|
||||
List<Artist> artists;
|
||||
List<Playlist> playlists;
|
||||
|
||||
SearchResults({this.tracks, this.albums, this.artists, this.playlists});
|
||||
|
||||
//Check if no search results
|
||||
bool get empty {
|
||||
return ((tracks == null || tracks.length == 0) &&
|
||||
(albums == null || albums.length == 0) &&
|
||||
(artists == null || artists.length == 0) &&
|
||||
(playlists == null || playlists.length == 0));
|
||||
}
|
||||
|
||||
factory SearchResults.fromPrivateJson(Map<dynamic, dynamic> json) => SearchResults(
|
||||
tracks: json['TRACK']['data'].map<Track>((dynamic data) => Track.fromPrivateJson(data)).toList(),
|
||||
albums: json['ALBUM']['data'].map<Album>((dynamic data) => Album.fromPrivateJson(data)).toList(),
|
||||
artists: json['ARTIST']['data'].map<Artist>((dynamic data) => Artist.fromPrivateJson(data)).toList(),
|
||||
playlists: json['PLAYLIST']['data'].map<Playlist>((dynamic data) => Playlist.fromPrivateJson(data)).toList()
|
||||
);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Lyrics {
|
||||
String id;
|
||||
String writers;
|
||||
List<Lyric> lyrics;
|
||||
|
||||
Lyrics({this.id, this.writers, this.lyrics});
|
||||
|
||||
Lyrics get error => Lyrics(
|
||||
id: id,
|
||||
writers: writers,
|
||||
lyrics: [Lyric(
|
||||
offset: Duration(milliseconds: 0),
|
||||
text: 'Error loading lyrics!'
|
||||
)]
|
||||
);
|
||||
|
||||
//JSON
|
||||
factory Lyrics.fromPrivateJson(Map<dynamic, dynamic> json) {
|
||||
Lyrics l = Lyrics(
|
||||
id: json['LYRICS_ID'],
|
||||
writers: json['LYRICS_WRITERS'],
|
||||
lyrics: json['LYRICS_SYNC_JSON'].map<Lyric>((l) => Lyric.fromPrivateJson(l)).toList()
|
||||
);
|
||||
//Clean empty lyrics
|
||||
l.lyrics.removeWhere((l) => l.offset == null);
|
||||
return l;
|
||||
}
|
||||
|
||||
factory Lyrics.fromJson(Map<String, dynamic> json) => _$LyricsFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$LyricsToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Lyric {
|
||||
Duration offset;
|
||||
String text;
|
||||
|
||||
Lyric({this.offset, this.text});
|
||||
|
||||
//JSON
|
||||
factory Lyric.fromPrivateJson(Map<dynamic, dynamic> json) {
|
||||
if (json['milliseconds'] == null || json['line'] == null) return Lyric(); //Empty lyric
|
||||
return Lyric(
|
||||
offset: Duration(milliseconds: int.parse(json['milliseconds'].toString())),
|
||||
text: json['line']
|
||||
);
|
||||
}
|
||||
|
||||
factory Lyric.fromJson(Map<String, dynamic> json) => _$LyricFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$LyricToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class QueueSource {
|
||||
String id;
|
||||
String text;
|
||||
String source;
|
||||
|
||||
QueueSource({this.id, this.text, this.source});
|
||||
|
||||
factory QueueSource.fromJson(Map<String, dynamic> json) => _$QueueSourceFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$QueueSourceToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class SmartTrackList {
|
||||
String id;
|
||||
String title;
|
||||
String subtitle;
|
||||
String description;
|
||||
int trackCount;
|
||||
List<Track> tracks;
|
||||
ImageDetails cover;
|
||||
|
||||
SmartTrackList({this.id, this.title, this.description, this.trackCount, this.tracks, this.cover, this.subtitle});
|
||||
|
||||
//JSON
|
||||
factory SmartTrackList.fromPrivateJson(Map<dynamic, dynamic> json, {Map<dynamic, dynamic> songsJson = const {}}) => SmartTrackList(
|
||||
id: json['SMARTTRACKLIST_ID'],
|
||||
title: json['TITLE'],
|
||||
subtitle: json['SUBTITLE'],
|
||||
description: json['DESCRIPTION'],
|
||||
trackCount: json['NB_SONG']??(songsJson['total']),
|
||||
tracks: (songsJson['data']??[]).map<Track>((t) => Track.fromPrivateJson(t)).toList(),
|
||||
cover: ImageDetails.fromPrivateJson(json['COVER'])
|
||||
);
|
||||
|
||||
factory SmartTrackList.fromJson(Map<String, dynamic> json) => _$SmartTrackListFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$SmartTrackListToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class HomePage {
|
||||
|
||||
List<HomePageSection> sections;
|
||||
|
||||
HomePage({this.sections});
|
||||
|
||||
//Save/Load
|
||||
Future<String> _getPath() async {
|
||||
Directory d = await getApplicationDocumentsDirectory();
|
||||
return p.join(d.path, 'homescreen.json');
|
||||
}
|
||||
Future exists() async {
|
||||
String path = await _getPath();
|
||||
return await File(path).exists();
|
||||
}
|
||||
Future save() async {
|
||||
String path = await _getPath();
|
||||
await File(path).writeAsString(jsonEncode(this.toJson()));
|
||||
}
|
||||
Future<HomePage> load() async {
|
||||
String path = await _getPath();
|
||||
Map data = jsonDecode(await File(path).readAsString());
|
||||
return HomePage.fromJson(data);
|
||||
}
|
||||
|
||||
//JSON
|
||||
factory HomePage.fromPrivateJson(Map<dynamic, dynamic> json) {
|
||||
HomePage hp = HomePage(sections: []);
|
||||
//Parse every section
|
||||
for (var s in (json['sections']??[])) {
|
||||
HomePageSection section = HomePageSection.fromPrivateJson(s);
|
||||
if (section != null) hp.sections.add(section);
|
||||
}
|
||||
return hp;
|
||||
}
|
||||
|
||||
factory HomePage.fromJson(Map<String, dynamic> json) => _$HomePageFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$HomePageToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class HomePageSection {
|
||||
|
||||
String title;
|
||||
HomePageSectionLayout layout;
|
||||
|
||||
@JsonKey(fromJson: _homePageItemFromJson, toJson: _homePageItemToJson)
|
||||
List<HomePageItem> items;
|
||||
|
||||
HomePageSection({this.layout, this.items, this.title});
|
||||
|
||||
//JSON
|
||||
factory HomePageSection.fromPrivateJson(Map<dynamic, dynamic> json) {
|
||||
HomePageSection hps = HomePageSection(title: json['title'], items: []);
|
||||
String layout = json['layout'];
|
||||
//No ads there
|
||||
if (layout == 'ads') return null;
|
||||
if (layout == 'horizontal-grid' || layout == 'grid') {
|
||||
hps.layout = HomePageSectionLayout.ROW;
|
||||
} else {
|
||||
//Currently only row layout
|
||||
return null;
|
||||
}
|
||||
//Parse items
|
||||
for (var i in (json['items']??[])) {
|
||||
HomePageItem hpi = HomePageItem.fromPrivateJson(i);
|
||||
if (hpi != null) hps.items.add(hpi);
|
||||
}
|
||||
return hps;
|
||||
}
|
||||
|
||||
factory HomePageSection.fromJson(Map<String, dynamic> json) => _$HomePageSectionFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$HomePageSectionToJson(this);
|
||||
|
||||
static _homePageItemFromJson(json) => json.map<HomePageItem>((d) => HomePageItem.fromJson(d)).toList();
|
||||
static _homePageItemToJson(items) => items.map((i) => i.toJson()).toList();
|
||||
}
|
||||
|
||||
class HomePageItem {
|
||||
HomePageItemType type;
|
||||
dynamic value;
|
||||
|
||||
HomePageItem({this.type, this.value});
|
||||
|
||||
factory HomePageItem.fromPrivateJson(Map<dynamic, dynamic> json) {
|
||||
String type = json['type'];
|
||||
switch (type) {
|
||||
//Smart Track List
|
||||
case 'flow':
|
||||
case 'smarttracklist':
|
||||
return HomePageItem(type: HomePageItemType.SMARTTRACKLIST, value: SmartTrackList.fromPrivateJson(json['data']));
|
||||
case 'playlist':
|
||||
return HomePageItem(type: HomePageItemType.PLAYLIST, value: Playlist.fromPrivateJson(json['data']));
|
||||
case 'artist':
|
||||
return HomePageItem(type: HomePageItemType.ARTIST, value: Artist.fromPrivateJson(json['data']));
|
||||
case 'channel':
|
||||
return HomePageItem(type: HomePageItemType.CHANNEL, value: DeezerChannel.fromPrivateJson(json));
|
||||
case 'album':
|
||||
return HomePageItem(type: HomePageItemType.ALBUM, value: Album.fromPrivateJson(json['data']));
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
factory HomePageItem.fromJson(Map<String, dynamic> json) {
|
||||
String _t = json['type'];
|
||||
switch (_t) {
|
||||
case 'SMARTTRACKLIST':
|
||||
return HomePageItem(type: HomePageItemType.SMARTTRACKLIST, value: SmartTrackList.fromJson(json['value']));
|
||||
case 'PLAYLIST':
|
||||
return HomePageItem(type: HomePageItemType.PLAYLIST, value: Playlist.fromJson(json['value']));
|
||||
case 'ARTIST':
|
||||
return HomePageItem(type: HomePageItemType.ARTIST, value: Artist.fromJson(json['value']));
|
||||
case 'CHANNEL':
|
||||
return HomePageItem(type: HomePageItemType.CHANNEL, value: DeezerChannel.fromJson(json['value']));
|
||||
case 'ALBUM':
|
||||
return HomePageItem(type: HomePageItemType.ALBUM, value: Album.fromJson(json['value']));
|
||||
default:
|
||||
return HomePageItem();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
String type = this.type.toString().split('.').last;
|
||||
return {'type': type, 'value': value.toJson()};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class DeezerChannel {
|
||||
|
||||
String id;
|
||||
String target;
|
||||
String title;
|
||||
@JsonKey(fromJson: _colorFromJson, toJson: _colorToJson)
|
||||
Color backgroundColor;
|
||||
|
||||
DeezerChannel({this.id, this.title, this.backgroundColor, this.target});
|
||||
|
||||
factory DeezerChannel.fromPrivateJson(Map<dynamic, dynamic> json) => DeezerChannel(
|
||||
id: json['id'],
|
||||
title: json['title'],
|
||||
backgroundColor: Color(int.parse(json['background_color'].replaceFirst('#', 'FF'), radix: 16)),
|
||||
target: json['target'].replaceFirst('/', '')
|
||||
);
|
||||
|
||||
//JSON
|
||||
static _colorToJson(Color c) => c.value;
|
||||
static _colorFromJson(int v) => Color(v??Colors.blue.value);
|
||||
factory DeezerChannel.fromJson(Map<String, dynamic> json) => _$DeezerChannelFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$DeezerChannelToJson(this);
|
||||
}
|
||||
|
||||
enum HomePageItemType {
|
||||
SMARTTRACKLIST,
|
||||
PLAYLIST,
|
||||
ARTIST,
|
||||
CHANNEL,
|
||||
ALBUM
|
||||
}
|
||||
|
||||
enum HomePageSectionLayout {
|
||||
ROW
|
||||
}
|
||||
|
||||
enum RepeatType {
|
||||
NONE,
|
||||
LIST,
|
||||
TRACK
|
||||
}
|
338
lib/api/definitions.g.dart
Normal file
338
lib/api/definitions.g.dart
Normal file
|
@ -0,0 +1,338 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'definitions.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Track _$TrackFromJson(Map<String, dynamic> json) {
|
||||
return Track(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
duration: json['duration'] == null
|
||||
? null
|
||||
: Duration(microseconds: json['duration'] as int),
|
||||
album: json['album'] == null
|
||||
? null
|
||||
: Album.fromJson(json['album'] as Map<String, dynamic>),
|
||||
playbackDetails: json['playbackDetails'] as List,
|
||||
albumArt: json['albumArt'] == null
|
||||
? null
|
||||
: ImageDetails.fromJson(json['albumArt'] as Map<String, dynamic>),
|
||||
artists: (json['artists'] as List)
|
||||
?.map((e) =>
|
||||
e == null ? null : Artist.fromJson(e as Map<String, dynamic>))
|
||||
?.toList(),
|
||||
trackNumber: json['trackNumber'] as int,
|
||||
offline: json['offline'] as bool,
|
||||
lyrics: json['lyrics'] == null
|
||||
? null
|
||||
: Lyrics.fromJson(json['lyrics'] as Map<String, dynamic>),
|
||||
favorite: json['favorite'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'album': instance.album,
|
||||
'artists': instance.artists,
|
||||
'duration': instance.duration?.inMicroseconds,
|
||||
'albumArt': instance.albumArt,
|
||||
'trackNumber': instance.trackNumber,
|
||||
'offline': instance.offline,
|
||||
'lyrics': instance.lyrics,
|
||||
'favorite': instance.favorite,
|
||||
'playbackDetails': instance.playbackDetails,
|
||||
};
|
||||
|
||||
Album _$AlbumFromJson(Map<String, dynamic> json) {
|
||||
return Album(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
art: json['art'] == null
|
||||
? null
|
||||
: ImageDetails.fromJson(json['art'] as Map<String, dynamic>),
|
||||
artists: (json['artists'] as List)
|
||||
?.map((e) =>
|
||||
e == null ? null : Artist.fromJson(e as Map<String, dynamic>))
|
||||
?.toList(),
|
||||
tracks: (json['tracks'] as List)
|
||||
?.map(
|
||||
(e) => e == null ? null : Track.fromJson(e as Map<String, dynamic>))
|
||||
?.toList(),
|
||||
fans: json['fans'] as int,
|
||||
offline: json['offline'] as bool,
|
||||
library: json['library'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$AlbumToJson(Album instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'artists': instance.artists,
|
||||
'tracks': instance.tracks,
|
||||
'art': instance.art,
|
||||
'fans': instance.fans,
|
||||
'offline': instance.offline,
|
||||
'library': instance.library,
|
||||
};
|
||||
|
||||
Artist _$ArtistFromJson(Map<String, dynamic> json) {
|
||||
return Artist(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
albums: (json['albums'] as List)
|
||||
?.map(
|
||||
(e) => e == null ? null : Album.fromJson(e as Map<String, dynamic>))
|
||||
?.toList(),
|
||||
albumCount: json['albumCount'] as int,
|
||||
topTracks: (json['topTracks'] as List)
|
||||
?.map(
|
||||
(e) => e == null ? null : Track.fromJson(e as Map<String, dynamic>))
|
||||
?.toList(),
|
||||
picture: json['picture'] == null
|
||||
? null
|
||||
: ImageDetails.fromJson(json['picture'] as Map<String, dynamic>),
|
||||
fans: json['fans'] as int,
|
||||
offline: json['offline'] as bool,
|
||||
library: json['library'] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$ArtistToJson(Artist instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'albums': instance.albums,
|
||||
'albumCount': instance.albumCount,
|
||||
'topTracks': instance.topTracks,
|
||||
'picture': instance.picture,
|
||||
'fans': instance.fans,
|
||||
'offline': instance.offline,
|
||||
'library': instance.library,
|
||||
};
|
||||
|
||||
Playlist _$PlaylistFromJson(Map<String, dynamic> json) {
|
||||
return Playlist(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
tracks: (json['tracks'] as List)
|
||||
?.map(
|
||||
(e) => e == null ? null : Track.fromJson(e as Map<String, dynamic>))
|
||||
?.toList(),
|
||||
image: json['image'] == null
|
||||
? null
|
||||
: ImageDetails.fromJson(json['image'] as Map<String, dynamic>),
|
||||
trackCount: json['trackCount'] as int,
|
||||
duration: json['duration'] == null
|
||||
? null
|
||||
: Duration(microseconds: json['duration'] as int),
|
||||
user: json['user'] == null
|
||||
? null
|
||||
: User.fromJson(json['user'] as Map<String, dynamic>),
|
||||
fans: json['fans'] as int,
|
||||
library: json['library'] as bool,
|
||||
description: json['description'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$PlaylistToJson(Playlist instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'tracks': instance.tracks,
|
||||
'image': instance.image,
|
||||
'duration': instance.duration?.inMicroseconds,
|
||||
'trackCount': instance.trackCount,
|
||||
'user': instance.user,
|
||||
'fans': instance.fans,
|
||||
'library': instance.library,
|
||||
'description': instance.description,
|
||||
};
|
||||
|
||||
User _$UserFromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
picture: json['picture'] == null
|
||||
? null
|
||||
: ImageDetails.fromJson(json['picture'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'picture': instance.picture,
|
||||
};
|
||||
|
||||
ImageDetails _$ImageDetailsFromJson(Map<String, dynamic> json) {
|
||||
return ImageDetails(
|
||||
fullUrl: json['fullUrl'] as String,
|
||||
thumbUrl: json['thumbUrl'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$ImageDetailsToJson(ImageDetails instance) =>
|
||||
<String, dynamic>{
|
||||
'fullUrl': instance.fullUrl,
|
||||
'thumbUrl': instance.thumbUrl,
|
||||
};
|
||||
|
||||
Lyrics _$LyricsFromJson(Map<String, dynamic> json) {
|
||||
return Lyrics(
|
||||
id: json['id'] as String,
|
||||
writers: json['writers'] as String,
|
||||
lyrics: (json['lyrics'] as List)
|
||||
?.map(
|
||||
(e) => e == null ? null : Lyric.fromJson(e as Map<String, dynamic>))
|
||||
?.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$LyricsToJson(Lyrics instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'writers': instance.writers,
|
||||
'lyrics': instance.lyrics,
|
||||
};
|
||||
|
||||
Lyric _$LyricFromJson(Map<String, dynamic> json) {
|
||||
return Lyric(
|
||||
offset: json['offset'] == null
|
||||
? null
|
||||
: Duration(microseconds: json['offset'] as int),
|
||||
text: json['text'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$LyricToJson(Lyric instance) => <String, dynamic>{
|
||||
'offset': instance.offset?.inMicroseconds,
|
||||
'text': instance.text,
|
||||
};
|
||||
|
||||
QueueSource _$QueueSourceFromJson(Map<String, dynamic> json) {
|
||||
return QueueSource(
|
||||
id: json['id'] as String,
|
||||
text: json['text'] as String,
|
||||
source: json['source'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$QueueSourceToJson(QueueSource instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'text': instance.text,
|
||||
'source': instance.source,
|
||||
};
|
||||
|
||||
SmartTrackList _$SmartTrackListFromJson(Map<String, dynamic> json) {
|
||||
return SmartTrackList(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String,
|
||||
trackCount: json['trackCount'] as int,
|
||||
tracks: (json['tracks'] as List)
|
||||
?.map(
|
||||
(e) => e == null ? null : Track.fromJson(e as Map<String, dynamic>))
|
||||
?.toList(),
|
||||
cover: json['cover'] == null
|
||||
? null
|
||||
: ImageDetails.fromJson(json['cover'] as Map<String, dynamic>),
|
||||
subtitle: json['subtitle'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$SmartTrackListToJson(SmartTrackList instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'title': instance.title,
|
||||
'subtitle': instance.subtitle,
|
||||
'description': instance.description,
|
||||
'trackCount': instance.trackCount,
|
||||
'tracks': instance.tracks,
|
||||
'cover': instance.cover,
|
||||
};
|
||||
|
||||
HomePage _$HomePageFromJson(Map<String, dynamic> json) {
|
||||
return HomePage(
|
||||
sections: (json['sections'] as List)
|
||||
?.map((e) => e == null
|
||||
? null
|
||||
: HomePageSection.fromJson(e as Map<String, dynamic>))
|
||||
?.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$HomePageToJson(HomePage instance) => <String, dynamic>{
|
||||
'sections': instance.sections,
|
||||
};
|
||||
|
||||
HomePageSection _$HomePageSectionFromJson(Map<String, dynamic> json) {
|
||||
return HomePageSection(
|
||||
layout:
|
||||
_$enumDecodeNullable(_$HomePageSectionLayoutEnumMap, json['layout']),
|
||||
items: HomePageSection._homePageItemFromJson(json['items']),
|
||||
title: json['title'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$HomePageSectionToJson(HomePageSection instance) =>
|
||||
<String, dynamic>{
|
||||
'title': instance.title,
|
||||
'layout': _$HomePageSectionLayoutEnumMap[instance.layout],
|
||||
'items': HomePageSection._homePageItemToJson(instance.items),
|
||||
};
|
||||
|
||||
T _$enumDecode<T>(
|
||||
Map<T, dynamic> enumValues,
|
||||
dynamic source, {
|
||||
T unknownValue,
|
||||
}) {
|
||||
if (source == null) {
|
||||
throw ArgumentError('A value must be provided. Supported values: '
|
||||
'${enumValues.values.join(', ')}');
|
||||
}
|
||||
|
||||
final value = enumValues.entries
|
||||
.singleWhere((e) => e.value == source, orElse: () => null)
|
||||
?.key;
|
||||
|
||||
if (value == null && unknownValue == null) {
|
||||
throw ArgumentError('`$source` is not one of the supported values: '
|
||||
'${enumValues.values.join(', ')}');
|
||||
}
|
||||
return value ?? unknownValue;
|
||||
}
|
||||
|
||||
T _$enumDecodeNullable<T>(
|
||||
Map<T, dynamic> enumValues,
|
||||
dynamic source, {
|
||||
T unknownValue,
|
||||
}) {
|
||||
if (source == null) {
|
||||
return null;
|
||||
}
|
||||
return _$enumDecode<T>(enumValues, source, unknownValue: unknownValue);
|
||||
}
|
||||
|
||||
const _$HomePageSectionLayoutEnumMap = {
|
||||
HomePageSectionLayout.ROW: 'ROW',
|
||||
};
|
||||
|
||||
DeezerChannel _$DeezerChannelFromJson(Map<String, dynamic> json) {
|
||||
return DeezerChannel(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
backgroundColor:
|
||||
DeezerChannel._colorFromJson(json['backgroundColor'] as int),
|
||||
target: json['target'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$DeezerChannelToJson(DeezerChannel instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'target': instance.target,
|
||||
'title': instance.title,
|
||||
'backgroundColor': DeezerChannel._colorToJson(instance.backgroundColor),
|
||||
};
|
577
lib/api/download.dart
Normal file
577
lib/api/download.dart
Normal file
|
@ -0,0 +1,577 @@
|
|||
import 'package:disk_space/disk_space.dart';
|
||||
import 'package:ext_storage/ext_storage.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:random_string/random_string.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'dart:async';
|
||||
import 'deezer.dart';
|
||||
import '../settings.dart';
|
||||
import 'definitions.dart';
|
||||
import '../ui/cached_image.dart';
|
||||
|
||||
DownloadManager downloadManager = DownloadManager();
|
||||
MethodChannel platformChannel = const MethodChannel('f.f.freezer/native');
|
||||
|
||||
class DownloadManager {
|
||||
|
||||
Database db;
|
||||
List<Download> queue = [];
|
||||
String _offlinePath;
|
||||
Future _download;
|
||||
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin;
|
||||
bool _cancelNotifications = true;
|
||||
|
||||
bool get stopped => queue.length > 0 && _download == null;
|
||||
|
||||
Future init() async {
|
||||
//Prepare DB
|
||||
String dir = await getDatabasesPath();
|
||||
String path = p.join(dir, 'offline.db');
|
||||
db = await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
onCreate: (Database db, int version) async {
|
||||
Batch b = db.batch();
|
||||
//Create tables
|
||||
b.execute(""" CREATE TABLE downloads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT, url TEXT, private INTEGER, state INTEGER, trackId TEXT)""");
|
||||
b.execute("""CREATE TABLE tracks (
|
||||
id TEXT PRIMARY KEY, title TEXT, album TEXT, artists TEXT, duration INTEGER, albumArt TEXT, trackNumber INTEGER, offline INTEGER, lyrics TEXT, favorite INTEGER)""");
|
||||
b.execute("""CREATE TABLE albums (
|
||||
id TEXT PRIMARY KEY, title TEXT, artists TEXT, tracks TEXT, art TEXT, fans INTEGER, offline INTEGER, library INTEGER)""");
|
||||
b.execute("""CREATE TABLE artists (
|
||||
id TEXT PRIMARY KEY, name TEXT, albums TEXT, topTracks TEXT, picture TEXT, fans INTEGER, albumCount INTEGER, offline INTEGER, library INTEGER)""");
|
||||
b.execute("""CREATE TABLE playlists (
|
||||
id TEXT PRIMARY KEY, title TEXT, tracks TEXT, image TEXT, duration INTEGER, userId TEXT, userName TEXT, fans INTEGER, library INTEGER, description TEXT)""");
|
||||
await b.commit();
|
||||
}
|
||||
);
|
||||
//Prepare folders (/sdcard/Android/data/freezer/data/)
|
||||
_offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
|
||||
await Directory(_offlinePath).create(recursive: true);
|
||||
|
||||
//Notifications
|
||||
await _prepareNotifications();
|
||||
|
||||
//Restore
|
||||
List<Map> downloads = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 0");
|
||||
downloads.forEach((download) => queue.add(Download.fromSQL(download, parseTrack: true)));
|
||||
}
|
||||
|
||||
//Initialize flutter local notification plugin
|
||||
Future _prepareNotifications() async {
|
||||
flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
|
||||
AndroidInitializationSettings androidInitializationSettings = AndroidInitializationSettings('@drawable/ic_logo');
|
||||
InitializationSettings initializationSettings = InitializationSettings(androidInitializationSettings, null);
|
||||
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
|
||||
}
|
||||
|
||||
//Show download progress notification, if now/total = null, show intermediate
|
||||
Future _startProgressNotification() async {
|
||||
_cancelNotifications = false;
|
||||
Timer.periodic(Duration(milliseconds: 500), (timer) async {
|
||||
//Cancel notifications
|
||||
if (_cancelNotifications) {
|
||||
flutterLocalNotificationsPlugin.cancel(10);
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
//Not downloading
|
||||
if (this.queue.length <= 0) return;
|
||||
Download d = queue[0];
|
||||
//Prepare and show notification
|
||||
AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails(
|
||||
'download', 'Download', 'Download',
|
||||
importance: Importance.Default,
|
||||
priority: Priority.Default,
|
||||
showProgress: true,
|
||||
maxProgress: d.total??1,
|
||||
progress: d.received??1,
|
||||
playSound: false,
|
||||
enableVibration: false,
|
||||
autoCancel: true,
|
||||
//ongoing: true, //Allow dismissing
|
||||
indeterminate: (d.total == null || d.total == d.received),
|
||||
onlyAlertOnce: true
|
||||
);
|
||||
NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null);
|
||||
await downloadManager.flutterLocalNotificationsPlugin.show(
|
||||
10,
|
||||
'Downloading: ${d.track.title}',
|
||||
(d.state == DownloadState.POST) ? 'Post processing...' : '${filesize(d.received)} / ${filesize(d.total)} (${queue.length} in queue)',
|
||||
notificationDetails
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
//Update queue, start new download
|
||||
void updateQueue() async {
|
||||
if (_download == null && queue.length > 0) {
|
||||
_download = queue[0].download(
|
||||
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
|
||||
if (queue.length == 0) {
|
||||
_cancelNotifications = true;
|
||||
}
|
||||
updateQueue();
|
||||
}
|
||||
).catchError((err) async {
|
||||
//Catch download errors
|
||||
_download = null;
|
||||
_cancelNotifications = true;
|
||||
await _showError();
|
||||
});
|
||||
//Show download progress notifications
|
||||
if (_cancelNotifications == null || _cancelNotifications) _startProgressNotification();
|
||||
}
|
||||
}
|
||||
|
||||
//Show error notification
|
||||
Future _showError() async {
|
||||
AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails(
|
||||
'downloadError', 'Download Error', 'Download Error'
|
||||
);
|
||||
NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null);
|
||||
flutterLocalNotificationsPlugin.show(
|
||||
11, 'Error while downloading!', 'Please restart downloads in the library', notificationDetails
|
||||
);
|
||||
}
|
||||
|
||||
//Returns all offline tracks
|
||||
Future<List<Track>> allOfflineTracks() async {
|
||||
List data = await db.query('tracks', where: 'offline == 1');
|
||||
List<Track> tracks = [];
|
||||
//Load track data
|
||||
for (var t in data) {
|
||||
tracks.add(await getTrack(t['id']));
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
//Get all offline playlists
|
||||
Future<List<Playlist>> getOfflinePlaylists() async {
|
||||
List data = await db.query('playlists');
|
||||
List<Playlist> playlists = [];
|
||||
//Load playlists
|
||||
for (var p in data) {
|
||||
playlists.add(await getPlaylist(p['id']));
|
||||
}
|
||||
return playlists;
|
||||
}
|
||||
|
||||
//Get playlist metadata with tracks
|
||||
Future<Playlist> getPlaylist(String id) async {
|
||||
if (id == null) return null;
|
||||
List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]);
|
||||
if (data.length == 0) return null;
|
||||
//Load playlist tracks
|
||||
Playlist p = Playlist.fromSQL(data[0]);
|
||||
for (int i=0; i<p.tracks.length; i++) {
|
||||
p.tracks[i] = await getTrack(p.tracks[i].id);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
//Gets favorites
|
||||
Future<Playlist> getFavorites() async {
|
||||
return await getPlaylist('FAVORITES');
|
||||
}
|
||||
|
||||
Future<List<Album>> getOfflineAlbums({List albumsData}) async {
|
||||
//Load albums
|
||||
if (albumsData == null) {
|
||||
albumsData = await db.query('albums', where: 'offline == 1');
|
||||
}
|
||||
List<Album> albums = albumsData.map((alb) => Album.fromSQL(alb)).toList();
|
||||
for(int i=0; i<albums.length; i++) {
|
||||
albums[i].library = true;
|
||||
//Load tracks
|
||||
for(int j=0; j<albums[i].tracks.length; j++) {
|
||||
albums[i].tracks[j] = await getTrack(albums[i].tracks[j].id, album: albums[i]);
|
||||
}
|
||||
//Load artists
|
||||
List artistsData = await db.rawQuery('SELECT * FROM artists WHERE id IN (${albumsData[i]['artists']})');
|
||||
albums[i].artists = artistsData.map<Artist>((a) => Artist.fromSQL(a)).toList();
|
||||
}
|
||||
return albums;
|
||||
}
|
||||
|
||||
//Get track with metadata from db
|
||||
Future<Track> getTrack(String id, {Album album, List<Artist> artists}) async {
|
||||
List tracks = await db.query('tracks', where: 'id == ?', whereArgs: [id]);
|
||||
if (tracks.length == 0) return null;
|
||||
Track t = Track.fromSQL(tracks[0]);
|
||||
//Load album from DB
|
||||
t.album = album ?? Album.fromSQL((await db.query('albums', where: 'id == ?', whereArgs: [t.album.id]))[0]);
|
||||
if (artists != null) {
|
||||
t.artists = artists;
|
||||
return t;
|
||||
}
|
||||
//Load artists from DB
|
||||
for (int i=0; i<t.artists.length; i++) {
|
||||
t.artists[i] = Artist.fromSQL(
|
||||
(await db.query('artists', where: 'id == ?', whereArgs: [t.artists[i].id]))[0]);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
Future removeOfflineTrack(String id) async {
|
||||
//Check if track present in albums
|
||||
List counter = await db.rawQuery('SELECT COUNT(*) FROM albums WHERE tracks LIKE "%$id%"');
|
||||
if (counter[0]['COUNT(*)'] > 0) return;
|
||||
//and in playlists
|
||||
counter = await db.rawQuery('SELECT COUNT(*) FROM playlists WHERE tracks LIKE "%$id%"');
|
||||
if (counter[0]['COUNT(*)'] > 0) return;
|
||||
//Remove file
|
||||
List download = await db.query('downloads', where: 'trackId == ?', whereArgs: [id]);
|
||||
await File(download[0]['path']).delete();
|
||||
//Delete from db
|
||||
await db.delete('tracks', where: 'id == ?', whereArgs: [id]);
|
||||
await db.delete('downloads', where: 'trackId == ?', whereArgs: [id]);
|
||||
}
|
||||
|
||||
//Delete offline album
|
||||
Future removeOfflineAlbum(String id) async {
|
||||
List data = await db.rawQuery('SELECT * FROM albums WHERE id == ? AND offline == 1', [id]);
|
||||
if (data.length == 0) return;
|
||||
Map<String, dynamic> album = Map.from(data[0]); //make writable
|
||||
//Remove DB
|
||||
album['offline'] = 0;
|
||||
await db.update('albums', album, where: 'id == ?', whereArgs: [id]);
|
||||
//Get track ids
|
||||
List<String> tracks = album['tracks'].split(',');
|
||||
for (String t in tracks) {
|
||||
//Remove tracks
|
||||
await removeOfflineTrack(t);
|
||||
}
|
||||
}
|
||||
|
||||
Future removeOfflinePlaylist(String id) async {
|
||||
List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]);
|
||||
if (data.length == 0) return;
|
||||
Playlist p = Playlist.fromSQL(data[0]);
|
||||
//Remove db
|
||||
await db.delete('playlists', where: 'id == ?', whereArgs: [id]);
|
||||
//Remove tracks
|
||||
for(Track t in p.tracks) {
|
||||
await removeOfflineTrack(t.id);
|
||||
}
|
||||
}
|
||||
|
||||
//Get path to offline track
|
||||
Future<String> getOfflineTrackPath(String id) async {
|
||||
List<Map> tracks = await db.rawQuery('SELECT path FROM downloads WHERE state == 1 AND trackId == ?', [id]);
|
||||
if (tracks.length < 1) {
|
||||
return null;
|
||||
}
|
||||
Download d = Download.fromSQL(tracks[0]);
|
||||
return d.path;
|
||||
}
|
||||
|
||||
Future addOfflineTrack(Track track, {private = true}) async {
|
||||
//Paths
|
||||
String path = p.join(_offlinePath, track.id);
|
||||
if (track.playbackDetails == null) {
|
||||
//Get track from API if download info missing
|
||||
track = await deezerAPI.track(track.id);
|
||||
}
|
||||
//Load lyrics
|
||||
try {
|
||||
Lyrics l = await deezerAPI.lyrics(track.id);
|
||||
track.lyrics = l;
|
||||
} catch (e) {}
|
||||
|
||||
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) {
|
||||
path = 'flac';
|
||||
}
|
||||
}
|
||||
|
||||
Download download = Download(track: track, path: path, url: url, private: private);
|
||||
//Database
|
||||
Batch b = db.batch();
|
||||
b.insert('downloads', download.toSQL());
|
||||
b.insert('tracks', track.toSQL(off: false), conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
|
||||
if (private) {
|
||||
//Duplicate check
|
||||
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);
|
||||
//Save to db
|
||||
b.insert('tracks', track.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
b.insert('albums', track.album.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore);
|
||||
track.artists.forEach((art) => b.insert('artists', art.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore));
|
||||
}
|
||||
await b.commit();
|
||||
|
||||
queue.add(download);
|
||||
updateQueue();
|
||||
}
|
||||
|
||||
Future addOfflineAlbum(Album album, {private = true}) async {
|
||||
//Get full album from API if tracks are missing
|
||||
if (album.tracks == null || album.tracks.length == 0) {
|
||||
album = await deezerAPI.album(album.id);
|
||||
}
|
||||
//Update album in database
|
||||
if (private) {
|
||||
await db.insert('albums', album.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
//Save all tracks
|
||||
for (Track track in album.tracks) {
|
||||
await addOfflineTrack(track, private: private);
|
||||
}
|
||||
}
|
||||
|
||||
//Add offline playlist, can be also used as update
|
||||
Future addOfflinePlaylist(Playlist playlist, {private = true}) async {
|
||||
//Load full playlist if missing tracks
|
||||
if (playlist.tracks == null || playlist.tracks.length != playlist.trackCount) {
|
||||
playlist = await deezerAPI.fullPlaylist(playlist.id);
|
||||
}
|
||||
playlist.library = true;
|
||||
//To DB
|
||||
if (private) {
|
||||
await db.insert('playlists', playlist.toSQL(), conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
//Download all tracks
|
||||
for (Track t in playlist.tracks) {
|
||||
await addOfflineTrack(t, private: private);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future checkOffline({Album album, Track track, Playlist playlist}) async {
|
||||
//Check if album/track (TODO: Artist, playlist) is offline
|
||||
if (track != null) {
|
||||
List res = await db.query('tracks', where: 'id == ? AND offline == 1', whereArgs: [track.id]);
|
||||
if (res.length == 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (album != null) {
|
||||
List res = await db.query('albums', where: 'id == ? AND offline == 1', whereArgs: [album.id]);
|
||||
if (res.length == 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (playlist != null && playlist.id != null) {
|
||||
List res = await db.query('playlists', where: 'id == ?', whereArgs: [playlist.id]);
|
||||
if (res.length == 0) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
//Offline search
|
||||
Future<SearchResults> search(String query) async {
|
||||
SearchResults results = SearchResults(
|
||||
tracks: [],
|
||||
albums: [],
|
||||
artists: [],
|
||||
playlists: []
|
||||
);
|
||||
//Tracks
|
||||
List tracksData = await db.rawQuery('SELECT * FROM tracks WHERE offline == 1 AND title like "%$query%"');
|
||||
for (Map trackData in tracksData) {
|
||||
results.tracks.add(await getTrack(trackData['id']));
|
||||
}
|
||||
//Albums
|
||||
List albumsData = await db.rawQuery('SELECT * FROM albums WHERE offline == 1 AND title like "%$query%"');
|
||||
results.albums = await getOfflineAlbums(albumsData: albumsData);
|
||||
//Artists
|
||||
//TODO: offline artists
|
||||
//Playlists
|
||||
List playlists = await db.rawQuery('SELECT * FROM playlists WHERE title like "%$query%"');
|
||||
for (Map playlist in playlists) {
|
||||
results.playlists.add(await getPlaylist(playlist['id']));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<List<Download>> getFinishedDownloads() async {
|
||||
//Fetch from db
|
||||
List<Map> data = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 1");
|
||||
List<Download> downloads = data.map<Download>((d) => Download.fromSQL(d, parseTrack: true)).toList();
|
||||
return downloads;
|
||||
}
|
||||
|
||||
//Get stats for library screen
|
||||
Future<List<String>> getStats() async {
|
||||
//Get offline counts
|
||||
int trackCount = (await db.rawQuery('SELECT COUNT(*) FROM tracks WHERE offline == 1'))[0]['COUNT(*)'];
|
||||
int albumCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)'];
|
||||
int playlistCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)'];
|
||||
//Free space
|
||||
double diskSpace = await DiskSpace.getFreeDiskSpace;
|
||||
|
||||
//Used space
|
||||
List<FileSystemEntity> offlineStat = await Directory(_offlinePath).list().toList();
|
||||
int offlineSize = 0;
|
||||
for (var fs in offlineStat) {
|
||||
offlineSize += (await fs.stat()).size;
|
||||
}
|
||||
|
||||
//Return as a list, maybe refactor in future if feature stays
|
||||
return ([
|
||||
trackCount.toString(),
|
||||
albumCount.toString(),
|
||||
playlistCount.toString(),
|
||||
filesize(offlineSize),
|
||||
filesize((diskSpace * 1000000).floor())
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
class Download {
|
||||
Track track;
|
||||
String path;
|
||||
String url;
|
||||
bool private;
|
||||
DownloadState state;
|
||||
String _cover;
|
||||
|
||||
int received = 0;
|
||||
int total = 1;
|
||||
|
||||
Download({this.track, this.path, this.url, this.private, this.state = DownloadState.NONE});
|
||||
|
||||
Future download({onDone}) async {
|
||||
Dio dio = Dio();
|
||||
|
||||
//TODO: Check for internet before downloading
|
||||
|
||||
if (!this.private) {
|
||||
String ext = this.path;
|
||||
//Get track details
|
||||
this.track = await deezerAPI.track(track.id);
|
||||
//Get path if public
|
||||
RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]');
|
||||
//Download path
|
||||
if (settings.downloadFolderStructure) {
|
||||
this.path = p.join(
|
||||
settings.downloadPath ?? (await ExtStorage.getExternalStoragePublicDirectory(ExtStorage.DIRECTORY_MUSIC)),
|
||||
track.artists[0].name.replaceAll(sanitize, ''),
|
||||
track.album.title.replaceAll(sanitize, ''),
|
||||
);
|
||||
} else {
|
||||
this.path = settings.downloadPath;
|
||||
}
|
||||
//Make dirs
|
||||
await Directory(this.path).create(recursive: true);
|
||||
|
||||
//Grab cover
|
||||
_cover = p.join(this.path, 'cover.jpg');
|
||||
if (!settings.downloadFolderStructure) _cover = p.join(this.path, randomAlpha(12) + '_cover.jpg');
|
||||
|
||||
if (!await File(_cover).exists()) {
|
||||
try {
|
||||
await dio.download(
|
||||
this.track.albumArt.full,
|
||||
_cover,
|
||||
);
|
||||
} catch (e) {print('Error downloading cover');}
|
||||
}
|
||||
|
||||
//Add filename
|
||||
String _filename = '${track.trackNumber.toString().padLeft(2, '0')}. ${track.title.replaceAll(sanitize, "")}.$ext';
|
||||
//Different naming types
|
||||
if (settings.downloadNaming == DownloadNaming.STANDALONE)
|
||||
_filename = '${track.artistString.replaceAll(sanitize, "")} - ${track.title.replaceAll(sanitize, "")}.$ext';
|
||||
|
||||
this.path = p.join(this.path, _filename);
|
||||
}
|
||||
//Download
|
||||
this.state = DownloadState.DOWNLOADING;
|
||||
|
||||
await dio.download(
|
||||
this.url,
|
||||
this.path + '.ENC',
|
||||
deleteOnError: true,
|
||||
onReceiveProgress: (rec, total) {
|
||||
this.received = rec;
|
||||
this.total = total;
|
||||
}
|
||||
);
|
||||
|
||||
this.state = DownloadState.POST;
|
||||
//Decrypt
|
||||
await platformChannel.invokeMethod('decryptTrack', {'id': track.id, 'path': path});
|
||||
//Tag
|
||||
if (!private) {
|
||||
//Tag track in native
|
||||
await platformChannel.invokeMethod('tagTrack', {
|
||||
'path': path,
|
||||
'title': track.title,
|
||||
'album': track.album.title,
|
||||
'artists': track.artistString,
|
||||
'artist': track.artists[0].name,
|
||||
'cover': _cover,
|
||||
'trackNumber': track.trackNumber
|
||||
});
|
||||
}
|
||||
//Remove encrypted
|
||||
await File(path + '.ENC').delete();
|
||||
if (!settings.downloadFolderStructure) await File(_cover).delete();
|
||||
this.state = DownloadState.DONE;
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
//JSON
|
||||
Map<String, dynamic> toSQL() => {
|
||||
'trackId': track.id,
|
||||
'path': path,
|
||||
'url': url,
|
||||
'state': state == DownloadState.DONE ? 1:0,
|
||||
'private': private?1:0
|
||||
};
|
||||
factory Download.fromSQL(Map<String, dynamic> data, {parseTrack = false}) => Download(
|
||||
track: parseTrack?Track.fromSQL(data):Track(id: data['trackId']),
|
||||
path: data['path'],
|
||||
url: data['url'],
|
||||
state: data['state'] == 1 ? DownloadState.DONE:DownloadState.NONE,
|
||||
private: data['private'] == 1
|
||||
);
|
||||
}
|
||||
|
||||
enum DownloadState {
|
||||
NONE,
|
||||
DOWNLOADING,
|
||||
POST,
|
||||
DONE
|
||||
}
|
657
lib/api/player.dart
Normal file
657
lib/api/player.dart
Normal file
|
@ -0,0 +1,657 @@
|
|||
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;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'definitions.dart';
|
||||
import '../settings.dart';
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
PlayerHelper playerHelper = PlayerHelper();
|
||||
|
||||
class PlayerHelper {
|
||||
|
||||
StreamSubscription _customEventSubscription;
|
||||
StreamSubscription _playbackStateStreamSubscription;
|
||||
QueueSource queueSource;
|
||||
RepeatType repeatType = RepeatType.NONE;
|
||||
|
||||
//Find queue index by id
|
||||
int get queueIndex => 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;
|
||||
if (event['action'] == 'onLoad') {
|
||||
//After audio_service is loaded, load queue, set quality
|
||||
await settings.updateAudioServiceQuality();
|
||||
await AudioService.customAction('load');
|
||||
return;
|
||||
}
|
||||
if (event['action'] == 'onRestore') {
|
||||
//Load queueSource from isolate
|
||||
this.queueSource = QueueSource.fromJson(event['queueSource']);
|
||||
}
|
||||
if (event['action'] == 'queueEnd') {
|
||||
//If last song is played, load more queue
|
||||
this.queueSource = QueueSource.fromJson(event['queueSource']);
|
||||
print(queueSource.toJson());
|
||||
return;
|
||||
}
|
||||
});
|
||||
_playbackStateStreamSubscription = AudioService.playbackStateStream.listen((event) {
|
||||
//Log song (if allowed)
|
||||
if (event == null) return;
|
||||
if (event.processingState == AudioProcessingState.ready && event.playing) {
|
||||
if (settings.logListen) deezerAPI.logListen(AudioService.currentMediaItem.id);
|
||||
}
|
||||
});
|
||||
//Start audio_service
|
||||
_startService();
|
||||
}
|
||||
|
||||
Future _startService() async {
|
||||
if (AudioService.running) return;
|
||||
await AudioService.start(
|
||||
backgroundTaskEntrypoint: backgroundTaskEntrypoint,
|
||||
androidEnableQueue: true,
|
||||
androidStopForegroundOnPause: true,
|
||||
androidNotificationOngoing: false,
|
||||
androidNotificationClickStartsActivity: true,
|
||||
androidNotificationChannelDescription: 'Freezer',
|
||||
androidNotificationChannelName: 'Freezer',
|
||||
androidNotificationIcon: 'drawable/ic_logo'
|
||||
);
|
||||
}
|
||||
|
||||
//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;
|
||||
default:
|
||||
repeatType = RepeatType.NONE; break;
|
||||
}
|
||||
//Set repeat type
|
||||
await AudioService.customAction("repeatType", RepeatType.values.indexOf(repeatType));
|
||||
}
|
||||
|
||||
//Executed before exit
|
||||
Future onExit() async {
|
||||
_customEventSubscription.cancel();
|
||||
_playbackStateStreamSubscription.cancel();
|
||||
}
|
||||
|
||||
//Replace queue, play specified track id
|
||||
Future _loadQueuePlay(List<MediaItem> queue, String trackId) async {
|
||||
await _startService();
|
||||
await settings.updateAudioServiceQuality();
|
||||
await AudioService.updateQueue(queue);
|
||||
await AudioService.playFromMediaId(trackId);
|
||||
}
|
||||
|
||||
//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 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'
|
||||
));
|
||||
}
|
||||
//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/smart track lists.",
|
||||
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
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void backgroundTaskEntrypoint() async {
|
||||
AudioServiceBackground.run(() => AudioPlayerTask());
|
||||
}
|
||||
|
||||
class AudioPlayerTask extends BackgroundAudioTask {
|
||||
|
||||
AudioPlayer _audioPlayer = AudioPlayer();
|
||||
|
||||
List<MediaItem> _queue = <MediaItem>[];
|
||||
int _queueIndex = -1;
|
||||
|
||||
bool _playing;
|
||||
bool _interrupted;
|
||||
AudioProcessingState _skipState;
|
||||
Duration _lastPosition;
|
||||
|
||||
ImagesDatabase imagesDB;
|
||||
int mobileQuality;
|
||||
int wifiQuality;
|
||||
|
||||
StreamSubscription _eventSub;
|
||||
StreamSubscription _playerStateSub;
|
||||
|
||||
QueueSource queueSource;
|
||||
int repeatType = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
//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();
|
||||
|
||||
AudioServiceBackground.setQueue(_queue);
|
||||
AudioServiceBackground.sendCustomEvent({'action': 'onLoad'});
|
||||
}
|
||||
|
||||
@override
|
||||
Future onSkipToNext() {
|
||||
//If repeating allowed
|
||||
if (repeatType == 2) {
|
||||
_skip(0);
|
||||
return null;
|
||||
}
|
||||
_skip(1);
|
||||
}
|
||||
|
||||
@override
|
||||
Future onSkipToPrevious() => _skip(-1);
|
||||
|
||||
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);
|
||||
_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();
|
||||
}
|
||||
|
||||
@override
|
||||
void onPlayFromMediaId(String mediaId) async {
|
||||
int pos = this._queue.indexWhere((mi) => mi.id == mediaId);
|
||||
await _skip(pos - _queueIndex);
|
||||
if (_playing == null || !_playing) onPlay();
|
||||
}
|
||||
|
||||
@override
|
||||
Future onFastForward() async {
|
||||
await _seekRelative(fastForwardInterval);
|
||||
}
|
||||
|
||||
@override
|
||||
void onAddQueueItemAt(MediaItem mi, int index) {
|
||||
_queue.insert(index, mi);
|
||||
AudioServiceBackground.setQueue(_queue);
|
||||
_saveQueue();
|
||||
}
|
||||
|
||||
@override
|
||||
void onAddQueueItem(MediaItem mi) {
|
||||
_queue.add(mi);
|
||||
AudioServiceBackground.setQueue(_queue);
|
||||
_saveQueue();
|
||||
}
|
||||
|
||||
@override
|
||||
Future onRewind() async {
|
||||
await _seekRelative(rewindInterval);
|
||||
}
|
||||
|
||||
Future _seekRelative(Duration offset) async {
|
||||
Duration newPos = _audioPlayer.playbackEvent.position + offset;
|
||||
if (newPos < Duration.zero) newPos = Duration.zero;
|
||||
if (newPos > mediaItem.duration) newPos = mediaItem.duration;
|
||||
onSeekTo(_audioPlayer.playbackEvent.position + offset);
|
||||
}
|
||||
|
||||
//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;
|
||||
}
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Future onCustomAction(String name, dynamic args) async {
|
||||
if (name == 'updateQuality') {
|
||||
//Pass wifi & mobile quality by custom action
|
||||
//Isolate can't access globals
|
||||
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
|
||||
if (name == 'repeatType') {
|
||||
this.repeatType = 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';
|
||||
}
|
||||
//If file
|
||||
if (url.startsWith('/')) return 'file://' + url;
|
||||
return url;
|
||||
}
|
||||
|
||||
Future<String> _getTrackUri(MediaItem mi) 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
|
||||
);
|
||||
ConnectivityResult conn = await Connectivity().checkConnectivity();
|
||||
if (conn == ConnectivityResult.wifi) {
|
||||
return prefix + t.getUrl(wifiQuality);
|
||||
}
|
||||
return prefix + t.getUrl(mobileQuality);
|
||||
}
|
||||
|
||||
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
|
||||
int bitrate = ((size / 125) / duration.inSeconds).floor();
|
||||
return '$format ${bitrate}kbps ($source)';
|
||||
}
|
||||
|
||||
//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 {
|
||||
await _saveQueue();
|
||||
onStop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future onStop() async {
|
||||
await _saveQueue();
|
||||
|
||||
if (_playing != null) _audioPlayer.stop();
|
||||
if (_playerStateSub != null) _playerStateSub.cancel();
|
||||
if (_eventSub != null) _eventSub.cancel();
|
||||
|
||||
await 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
|
||||
Future<String> _getQueuePath() async {
|
||||
Directory dir = await getApplicationDocumentsDirectory();
|
||||
return p.join(dir.path, 'offline.json');
|
||||
}
|
||||
|
||||
//Export queue to JSON
|
||||
Future _saveQueue() async {
|
||||
print('save');
|
||||
File f = File(await _getQueuePath());
|
||||
await f.writeAsString(jsonEncode({
|
||||
'index': _queueIndex,
|
||||
'queue': _queue.map<Map<String, dynamic>>((mi) => mi.toJson()).toList(),
|
||||
'position': _audioPlayer.playbackEvent.position.inMilliseconds,
|
||||
'queueSource': (queueSource??QueueSource()).toJson(),
|
||||
}));
|
||||
}
|
||||
|
||||
Future _loadQueue() 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._lastPosition = Duration(milliseconds: json['position']??0);
|
||||
this.queueSource = QueueSource.fromJson(json['queueSource']??{});
|
||||
if (_queue != null) {
|
||||
AudioServiceBackground.setQueue(_queue);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue