Initial commit

This commit is contained in:
exttex 2020-06-23 21:23:12 +02:00
commit ed087bc583
123 changed files with 10390 additions and 0 deletions

356
lib/api/deezer.dart Normal file
View 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
View 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
View 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
View 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
View 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;
}
}
}