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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
175
lib/main.dart
Normal file
175
lib/main.dart
Normal file
|
@ -0,0 +1,175 @@
|
|||
import 'package:custom_navigator/custom_navigator.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/ui/library.dart';
|
||||
import 'package:freezer/ui/login_screen.dart';
|
||||
import 'package:freezer/ui/search.dart';
|
||||
import 'package:move_to_background/move_to_background.dart';
|
||||
|
||||
import 'ui/player_bar.dart';
|
||||
import 'api/deezer.dart';
|
||||
import 'settings.dart';
|
||||
import 'ui/cached_image.dart';
|
||||
import 'api/download.dart';
|
||||
import 'api/player.dart';
|
||||
import 'ui/home_screen.dart';
|
||||
|
||||
Function updateTheme;
|
||||
GlobalKey<NavigatorState> mainNavigatorKey = GlobalKey<NavigatorState>();
|
||||
GlobalKey<NavigatorState> navigatorKey;
|
||||
|
||||
void main() async {
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
//Initialize globals
|
||||
settings = await Settings().loadSettings();
|
||||
await imagesDatabase.init();
|
||||
await downloadManager.init();
|
||||
|
||||
runApp(FreezerApp());
|
||||
}
|
||||
|
||||
class FreezerApp extends StatefulWidget {
|
||||
@override
|
||||
_FreezerAppState createState() => _FreezerAppState();
|
||||
}
|
||||
|
||||
class _FreezerAppState extends State<FreezerApp> {
|
||||
@override
|
||||
void initState() {
|
||||
//Make update theme global
|
||||
updateTheme = _updateTheme;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _updateTheme() {
|
||||
setState(() {
|
||||
settings.themeData;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'freezer',
|
||||
theme: settings.themeData,
|
||||
home: WillPopScope(
|
||||
onWillPop: () async {
|
||||
//For some reason AudioServiceWidget caused the app to freeze after 2 back button presses. "fix"
|
||||
if (navigatorKey.currentState.canPop()) {
|
||||
await navigatorKey.currentState.maybePop();
|
||||
return false;
|
||||
}
|
||||
await MoveToBackground.moveTaskToBack();
|
||||
return false;
|
||||
},
|
||||
child: LoginMainWrapper(),
|
||||
),
|
||||
navigatorKey: mainNavigatorKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//Wrapper for login and main screen.
|
||||
class LoginMainWrapper extends StatefulWidget {
|
||||
@override
|
||||
_LoginMainWrapperState createState() => _LoginMainWrapperState();
|
||||
}
|
||||
|
||||
class _LoginMainWrapperState extends State<LoginMainWrapper> {
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (settings.arl != null) {
|
||||
playerHelper.start();
|
||||
//Load token on background
|
||||
deezerAPI.arl = settings.arl;
|
||||
settings.offlineMode = true;
|
||||
deezerAPI.authorize().then((b) {
|
||||
if (b) setState(() => settings.offlineMode = false);
|
||||
});
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (settings.arl == null)
|
||||
return LoginWidget(callback: () => setState(() => {}),);
|
||||
return MainScreen();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class MainScreen extends StatefulWidget {
|
||||
@override
|
||||
_MainScreenState createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen> {
|
||||
|
||||
List<Widget> _screens = [
|
||||
HomeScreen(),
|
||||
SearchScreen(),
|
||||
LibraryScreen()
|
||||
];
|
||||
int _selected = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
navigatorKey = GlobalKey<NavigatorState>();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
bottomNavigationBar: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
PlayerBar(),
|
||||
BottomNavigationBar(
|
||||
backgroundColor: Theme.of(context).bottomAppBarColor,
|
||||
currentIndex: _selected,
|
||||
onTap: (int s) async {
|
||||
//Pop all routes until home screen
|
||||
|
||||
while (navigatorKey.currentState.canPop()) {
|
||||
await navigatorKey.currentState.maybePop();
|
||||
}
|
||||
|
||||
await navigatorKey.currentState.maybePop();
|
||||
setState(() {
|
||||
_selected = s;
|
||||
});
|
||||
},
|
||||
selectedItemColor: Theme.of(context).primaryColor,
|
||||
items: <BottomNavigationBarItem>[
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home),
|
||||
title: Text('Home')
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.search),
|
||||
title: Text('Search'),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.library_music),
|
||||
title: Text('Library')
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
body: AudioServiceWidget(
|
||||
child: CustomNavigator(
|
||||
navigatorKey: navigatorKey,
|
||||
home: _screens[_selected],
|
||||
pageRoute: PageRoutes.materialPageRoute,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
194
lib/settings.dart
Normal file
194
lib/settings.dart
Normal file
|
@ -0,0 +1,194 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:freezer/main.dart';
|
||||
import 'package:freezer/ui/cached_image.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:ext_storage/ext_storage.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
part 'settings.g.dart';
|
||||
|
||||
Settings settings;
|
||||
|
||||
@JsonSerializable()
|
||||
class Settings {
|
||||
|
||||
//Account
|
||||
String arl;
|
||||
@JsonKey(ignore: true)
|
||||
bool offlineMode = false;
|
||||
|
||||
//Quality
|
||||
@JsonKey(defaultValue: AudioQuality.MP3_320)
|
||||
AudioQuality wifiQuality;
|
||||
@JsonKey(defaultValue: AudioQuality.MP3_128)
|
||||
AudioQuality mobileQuality;
|
||||
@JsonKey(defaultValue: AudioQuality.FLAC)
|
||||
AudioQuality offlineQuality;
|
||||
@JsonKey(defaultValue: AudioQuality.FLAC)
|
||||
AudioQuality downloadQuality;
|
||||
|
||||
//Download options
|
||||
String downloadPath;
|
||||
@JsonKey(defaultValue: DownloadNaming.DEFAULT)
|
||||
DownloadNaming downloadNaming;
|
||||
@JsonKey(defaultValue: true)
|
||||
bool downloadFolderStructure;
|
||||
|
||||
//Appearance
|
||||
@JsonKey(defaultValue: Themes.Light)
|
||||
Themes theme;
|
||||
|
||||
//Colors
|
||||
@JsonKey(toJson: _colorToJson, fromJson: _colorFromJson)
|
||||
Color primaryColor = Colors.blue;
|
||||
|
||||
static _colorToJson(Color c) => c.value;
|
||||
static _colorFromJson(int v) => Color(v??Colors.blue.value);
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
bool useArtColor = false;
|
||||
StreamSubscription _useArtColorSub;
|
||||
|
||||
|
||||
//Deezer
|
||||
@JsonKey(defaultValue: 'en')
|
||||
String deezerLanguage;
|
||||
@JsonKey(defaultValue: 'US')
|
||||
String deezerCountry;
|
||||
@JsonKey(defaultValue: false)
|
||||
bool logListen;
|
||||
|
||||
Settings({this.downloadPath, this.arl});
|
||||
|
||||
ThemeData get themeData {
|
||||
switch (theme??Themes.Light) {
|
||||
case Themes.Light:
|
||||
return ThemeData(
|
||||
fontFamily: 'Montserrat',
|
||||
primaryColor: primaryColor,
|
||||
accentColor: primaryColor,
|
||||
sliderTheme: _sliderTheme,
|
||||
toggleableActiveColor: primaryColor,
|
||||
);
|
||||
case Themes.Dark:
|
||||
return ThemeData(
|
||||
fontFamily: 'Montserrat',
|
||||
brightness: Brightness.dark,
|
||||
primaryColor: primaryColor,
|
||||
accentColor: primaryColor,
|
||||
sliderTheme: _sliderTheme,
|
||||
toggleableActiveColor: primaryColor,
|
||||
);
|
||||
case Themes.Black:
|
||||
return ThemeData(
|
||||
fontFamily: 'Montserrat',
|
||||
brightness: Brightness.dark,
|
||||
primaryColor: primaryColor,
|
||||
accentColor: primaryColor,
|
||||
backgroundColor: Colors.black,
|
||||
scaffoldBackgroundColor: Colors.black,
|
||||
bottomAppBarColor: Colors.black,
|
||||
dialogBackgroundColor: Colors.black,
|
||||
sliderTheme: _sliderTheme,
|
||||
toggleableActiveColor: primaryColor,
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
backgroundColor: Colors.black
|
||||
)
|
||||
);
|
||||
}
|
||||
return ThemeData();
|
||||
}
|
||||
|
||||
|
||||
void updateUseArtColor(bool v) {
|
||||
useArtColor = v;
|
||||
if (v) {
|
||||
//On media item change set color
|
||||
_useArtColorSub = AudioService.currentMediaItemStream.listen((event) async {
|
||||
if (event == null || event.artUri == null) return;
|
||||
this.primaryColor = await imagesDatabase.getPrimaryColor(event.artUri);
|
||||
updateTheme();
|
||||
});
|
||||
} else {
|
||||
//Cancel stream subscription
|
||||
if (_useArtColorSub != null) {
|
||||
_useArtColorSub.cancel();
|
||||
_useArtColorSub = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SliderThemeData get _sliderTheme => SliderThemeData(
|
||||
thumbColor: primaryColor,
|
||||
activeTrackColor: primaryColor,
|
||||
inactiveTrackColor: primaryColor.withOpacity(0.2)
|
||||
);
|
||||
|
||||
//Load settings/init
|
||||
Future<Settings> loadSettings() async {
|
||||
String path = await getPath();
|
||||
File f = File(path);
|
||||
if (await f.exists()) {
|
||||
String data = await f.readAsString();
|
||||
return Settings.fromJson(jsonDecode(data));
|
||||
}
|
||||
Settings s = Settings.fromJson({});
|
||||
//Set default path, because async
|
||||
s.downloadPath = (await ExtStorage.getExternalStoragePublicDirectory(ExtStorage.DIRECTORY_MUSIC));
|
||||
s.save();
|
||||
return s;
|
||||
}
|
||||
|
||||
Future save() async {
|
||||
File f = File(await getPath());
|
||||
await f.writeAsString(jsonEncode(this.toJson()));
|
||||
}
|
||||
|
||||
Future updateAudioServiceQuality() async {
|
||||
//Send wifi & mobile quality to audio service isolate
|
||||
await AudioService.customAction('updateQuality', {
|
||||
'mobileQuality': getQualityInt(mobileQuality),
|
||||
'wifiQuality': getQualityInt(wifiQuality)
|
||||
});
|
||||
}
|
||||
|
||||
//AudioQuality to deezer int
|
||||
int getQualityInt(AudioQuality q) {
|
||||
switch (q) {
|
||||
case AudioQuality.MP3_128: return 1;
|
||||
case AudioQuality.MP3_320: return 3;
|
||||
case AudioQuality.FLAC: return 9;
|
||||
}
|
||||
return 8; //default
|
||||
}
|
||||
|
||||
Future<String> getPath() async => p.join((await getApplicationDocumentsDirectory()).path, 'settings.json');
|
||||
|
||||
//JSON
|
||||
factory Settings.fromJson(Map<String, dynamic> json) => _$SettingsFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$SettingsToJson(this);
|
||||
}
|
||||
|
||||
enum AudioQuality {
|
||||
MP3_128,
|
||||
MP3_320,
|
||||
FLAC
|
||||
}
|
||||
|
||||
enum Themes {
|
||||
Light,
|
||||
Dark,
|
||||
Black
|
||||
}
|
||||
|
||||
enum DownloadNaming {
|
||||
DEFAULT,
|
||||
STANDALONE
|
||||
}
|
103
lib/settings.g.dart
Normal file
103
lib/settings.g.dart
Normal file
|
@ -0,0 +1,103 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'settings.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Settings _$SettingsFromJson(Map<String, dynamic> json) {
|
||||
return Settings(
|
||||
downloadPath: json['downloadPath'] as String,
|
||||
arl: json['arl'] as String,
|
||||
)
|
||||
..wifiQuality =
|
||||
_$enumDecodeNullable(_$AudioQualityEnumMap, json['wifiQuality']) ??
|
||||
AudioQuality.MP3_320
|
||||
..mobileQuality =
|
||||
_$enumDecodeNullable(_$AudioQualityEnumMap, json['mobileQuality']) ??
|
||||
AudioQuality.MP3_128
|
||||
..offlineQuality =
|
||||
_$enumDecodeNullable(_$AudioQualityEnumMap, json['offlineQuality']) ??
|
||||
AudioQuality.FLAC
|
||||
..downloadQuality =
|
||||
_$enumDecodeNullable(_$AudioQualityEnumMap, json['downloadQuality']) ??
|
||||
AudioQuality.FLAC
|
||||
..downloadNaming =
|
||||
_$enumDecodeNullable(_$DownloadNamingEnumMap, json['downloadNaming']) ??
|
||||
DownloadNaming.DEFAULT
|
||||
..downloadFolderStructure = json['downloadFolderStructure'] as bool ?? true
|
||||
..theme =
|
||||
_$enumDecodeNullable(_$ThemesEnumMap, json['theme']) ?? Themes.Light
|
||||
..primaryColor = Settings._colorFromJson(json['primaryColor'] as int)
|
||||
..useArtColor = json['useArtColor'] as bool ?? false
|
||||
..deezerLanguage = json['deezerLanguage'] as String ?? 'en'
|
||||
..deezerCountry = json['deezerCountry'] as String ?? 'US'
|
||||
..logListen = json['logListen'] as bool ?? false;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
||||
'arl': instance.arl,
|
||||
'wifiQuality': _$AudioQualityEnumMap[instance.wifiQuality],
|
||||
'mobileQuality': _$AudioQualityEnumMap[instance.mobileQuality],
|
||||
'offlineQuality': _$AudioQualityEnumMap[instance.offlineQuality],
|
||||
'downloadQuality': _$AudioQualityEnumMap[instance.downloadQuality],
|
||||
'downloadPath': instance.downloadPath,
|
||||
'downloadNaming': _$DownloadNamingEnumMap[instance.downloadNaming],
|
||||
'downloadFolderStructure': instance.downloadFolderStructure,
|
||||
'theme': _$ThemesEnumMap[instance.theme],
|
||||
'primaryColor': Settings._colorToJson(instance.primaryColor),
|
||||
'useArtColor': instance.useArtColor,
|
||||
'deezerLanguage': instance.deezerLanguage,
|
||||
'deezerCountry': instance.deezerCountry,
|
||||
'logListen': instance.logListen,
|
||||
};
|
||||
|
||||
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 _$AudioQualityEnumMap = {
|
||||
AudioQuality.MP3_128: 'MP3_128',
|
||||
AudioQuality.MP3_320: 'MP3_320',
|
||||
AudioQuality.FLAC: 'FLAC',
|
||||
};
|
||||
|
||||
const _$DownloadNamingEnumMap = {
|
||||
DownloadNaming.DEFAULT: 'DEFAULT',
|
||||
DownloadNaming.STANDALONE: 'STANDALONE',
|
||||
};
|
||||
|
||||
const _$ThemesEnumMap = {
|
||||
Themes.Light: 'Light',
|
||||
Themes.Dark: 'Dark',
|
||||
Themes.Black: 'Black',
|
||||
};
|
203
lib/ui/cached_image.dart
Normal file
203
lib/ui/cached_image.dart
Normal file
|
@ -0,0 +1,203 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
ImagesDatabase imagesDatabase = ImagesDatabase();
|
||||
|
||||
class ImagesDatabase {
|
||||
|
||||
/*
|
||||
images.db:
|
||||
Table: images
|
||||
Fields:
|
||||
id - id
|
||||
name - md5 hash of url. also filename
|
||||
url - url
|
||||
permanent - 0/1 - if image is cached or offline
|
||||
*/
|
||||
|
||||
|
||||
Database db;
|
||||
String imagesPath;
|
||||
|
||||
//Prepare database
|
||||
Future init() async {
|
||||
String dir = await getDatabasesPath();
|
||||
String path = p.join(dir, 'images.db');
|
||||
db = await openDatabase(
|
||||
path,
|
||||
version: 1,
|
||||
singleInstance: false,
|
||||
onCreate: (Database db, int version) async {
|
||||
//Create table on db created
|
||||
await db.execute('CREATE TABLE images (id INTEGER PRIMARY KEY, name TEXT, url TEXT, permanent INTEGER)');
|
||||
}
|
||||
);
|
||||
//Prepare folders
|
||||
imagesPath = p.join((await getApplicationDocumentsDirectory()).path, 'images/');
|
||||
Directory imagesDir = Directory(imagesPath);
|
||||
await imagesDir.create(recursive: true);
|
||||
}
|
||||
|
||||
String getPath(String name) {
|
||||
return p.join(imagesPath, name);
|
||||
}
|
||||
|
||||
//Get image url/path, cache it
|
||||
Future<String> getImage(String url, {bool permanent = false}) async {
|
||||
//Already file
|
||||
if (!url.startsWith('http')) {
|
||||
url = url.replaceFirst('file://', '');
|
||||
if (!permanent) return url;
|
||||
//Update in db to permanent
|
||||
String name = p.basenameWithoutExtension(url);
|
||||
await db.rawUpdate('UPDATE images SET permanent == 1 WHERE name == ?', [name]);
|
||||
}
|
||||
//Filename = md5 hash
|
||||
String hash = md5.convert(utf8.encode(url)).toString();
|
||||
List<Map> results = await db.rawQuery('SELECT * FROM images WHERE name == ?', [hash]);
|
||||
String path = getPath(hash);
|
||||
if (results.length > 0) {
|
||||
//Image in database
|
||||
return path;
|
||||
}
|
||||
//Save image
|
||||
Dio dio = Dio();
|
||||
try {
|
||||
await dio.download(url, path);
|
||||
await db.insert('images', {'url': url, 'name': hash, 'permanent': permanent?1:0});
|
||||
return path;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<PaletteGenerator> getPaletteGenerator(String url) async {
|
||||
String path = await getImage(url);
|
||||
//Get image provider
|
||||
ImageProvider provider = AssetImage('assets/cover.jpg');
|
||||
if (path != null) {
|
||||
provider = FileImage(File(path));
|
||||
}
|
||||
PaletteGenerator paletteGenerator = await PaletteGenerator.fromImageProvider(provider);
|
||||
return paletteGenerator;
|
||||
}
|
||||
|
||||
//Get primary color from album art
|
||||
Future<Color> getPrimaryColor(String url) async {
|
||||
PaletteGenerator paletteGenerator = await getPaletteGenerator(url);
|
||||
return paletteGenerator.colors.first;
|
||||
}
|
||||
|
||||
//Check if is dark
|
||||
Future<bool> isDark(String url) async {
|
||||
PaletteGenerator paletteGenerator = await getPaletteGenerator(url);
|
||||
return paletteGenerator.colors.first.computeLuminance() > 0.5 ? false : true;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
class CachedImage extends StatefulWidget {
|
||||
|
||||
final String url;
|
||||
final double width;
|
||||
final double height;
|
||||
final bool circular;
|
||||
|
||||
const CachedImage({Key key, this.url, this.height, this.width, this.circular = false}): super(key: key);
|
||||
|
||||
@override
|
||||
_CachedImageState createState() => _CachedImageState();
|
||||
}
|
||||
|
||||
class _CachedImageState extends State<CachedImage> {
|
||||
|
||||
final ImageProvider _placeholder = AssetImage('assets/cover.jpg');
|
||||
ImageProvider _image = AssetImage('assets/cover.jpg');
|
||||
double _opacity = 0.0;
|
||||
bool _disposed = false;
|
||||
|
||||
Future<ImageProvider> _getImage() async {
|
||||
//Image already path
|
||||
if (!widget.url.startsWith('http')) {
|
||||
//Remove file://, if used in audio_service
|
||||
if (widget.url.startsWith('/')) return FileImage(File(widget.url));
|
||||
return FileImage(File(widget.url.replaceFirst('file://', '')));
|
||||
}
|
||||
//Load image from db
|
||||
String path = await imagesDatabase.getImage(widget.url);
|
||||
if (path == null) return _placeholder;
|
||||
return FileImage(File(path));
|
||||
}
|
||||
|
||||
//Load image and fade
|
||||
void _load() async {
|
||||
ImageProvider image = await _getImage();
|
||||
if (_disposed) return;
|
||||
setState(() {
|
||||
_image = image;
|
||||
_opacity = 1.0;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_load();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(CachedImage oldWidget) {
|
||||
_load();
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
widget.circular ?
|
||||
CircleAvatar(
|
||||
radius: (widget.width??widget.height),
|
||||
backgroundImage: _placeholder,
|
||||
):
|
||||
Image(
|
||||
image: _placeholder,
|
||||
height: widget.height,
|
||||
width: widget.width,
|
||||
),
|
||||
AnimatedOpacity(
|
||||
duration: Duration(milliseconds: 250),
|
||||
opacity: _opacity,
|
||||
child: widget.circular ?
|
||||
CircleAvatar(
|
||||
radius: (widget.width??widget.height),
|
||||
backgroundImage: _image,
|
||||
):
|
||||
Image(
|
||||
image: _image,
|
||||
height: widget.height,
|
||||
width: widget.width,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
697
lib/ui/details_screens.dart
Normal file
697
lib/ui/details_screens.dart
Normal file
|
@ -0,0 +1,697 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/ui/error.dart';
|
||||
import 'package:freezer/ui/search.dart';
|
||||
|
||||
import '../api/definitions.dart';
|
||||
import 'player_bar.dart';
|
||||
import 'cached_image.dart';
|
||||
import 'tiles.dart';
|
||||
import 'menu.dart';
|
||||
|
||||
class AlbumDetails extends StatelessWidget {
|
||||
|
||||
Album album;
|
||||
|
||||
AlbumDetails(this.album);
|
||||
|
||||
Future _loadAlbum() async {
|
||||
//Get album from API, if doesn't have tracks
|
||||
if (this.album.tracks == null || this.album.tracks.length == 0) {
|
||||
this.album = await deezerAPI.album(album.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: FutureBuilder(
|
||||
future: _loadAlbum(),
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
|
||||
//Wait for data
|
||||
if (snapshot.connectionState != ConnectionState.done) return Center(child: CircularProgressIndicator(),);
|
||||
//On error
|
||||
if (snapshot.hasError) return ErrorScreen();
|
||||
|
||||
return ListView(
|
||||
children: <Widget>[
|
||||
//Album art, title, artists
|
||||
Card(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(height: 8.0,),
|
||||
CachedImage(
|
||||
url: album.art.full,
|
||||
height: 256.0,
|
||||
),
|
||||
Container(height: 8,),
|
||||
Text(
|
||||
album.title,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
style: TextStyle(
|
||||
fontSize: 20.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
Text(
|
||||
album.artistString,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
style: TextStyle(
|
||||
fontSize: 16.0,
|
||||
color: Theme.of(context).primaryColor
|
||||
),
|
||||
),
|
||||
Container(height: 8.0,),
|
||||
],
|
||||
),
|
||||
),
|
||||
//Details
|
||||
Card(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.audiotrack, size: 32.0,),
|
||||
Container(width: 8.0, height: 42.0,), //Height to adjust card height
|
||||
Text(
|
||||
album.tracks.length.toString(),
|
||||
style: TextStyle(fontSize: 16.0),
|
||||
)
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.timelapse, size: 32.0,),
|
||||
Container(width: 8.0,),
|
||||
Text(
|
||||
album.durationString,
|
||||
style: TextStyle(fontSize: 16.0),
|
||||
)
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.people, size: 32.0,),
|
||||
Container(width: 8.0,),
|
||||
Text(
|
||||
album.fansString,
|
||||
style: TextStyle(fontSize: 16.0),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
//Options (offline, download...)
|
||||
Card(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: <Widget>[
|
||||
FlatButton(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.favorite, size: 32),
|
||||
Container(width: 4,),
|
||||
Text('Library')
|
||||
],
|
||||
),
|
||||
onPressed: () async {
|
||||
await deezerAPI.addFavoriteAlbum(album.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Added to library',
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
},
|
||||
),
|
||||
MakeAlbumOffline(album: album),
|
||||
FlatButton(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.file_download, size: 32.0,),
|
||||
Container(width: 4,),
|
||||
Text('Download')
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
downloadManager.addOfflineAlbum(album, private: false);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
...List.generate(album.tracks.length, (i) {
|
||||
Track t = album.tracks[i];
|
||||
return TrackTile(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromAlbum(album, t.id);
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t);
|
||||
}
|
||||
);
|
||||
})
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MakeAlbumOffline extends StatefulWidget {
|
||||
|
||||
Album album;
|
||||
MakeAlbumOffline({Key key, this.album}): super(key: key);
|
||||
|
||||
@override
|
||||
_MakeAlbumOfflineState createState() => _MakeAlbumOfflineState();
|
||||
}
|
||||
|
||||
class _MakeAlbumOfflineState extends State<MakeAlbumOffline> {
|
||||
|
||||
bool _offline = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
downloadManager.checkOffline(album: widget.album).then((v) {
|
||||
setState(() {
|
||||
_offline = v;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Switch(
|
||||
value: _offline,
|
||||
onChanged: (v) async {
|
||||
if (v) {
|
||||
//Add to offline
|
||||
await deezerAPI.addFavoriteAlbum(widget.album.id);
|
||||
downloadManager.addOfflineAlbum(widget.album, private: true);
|
||||
setState(() {
|
||||
_offline = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
downloadManager.removeOfflineAlbum(widget.album.id);
|
||||
setState(() {
|
||||
_offline = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Container(width: 4.0,),
|
||||
Text(
|
||||
'Offline',
|
||||
style: TextStyle(fontSize: 16),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ArtistDetails extends StatelessWidget {
|
||||
|
||||
Artist artist;
|
||||
ArtistDetails(this.artist);
|
||||
|
||||
Future _loadArtist() async {
|
||||
//Load artist from api if no albums
|
||||
if ((this.artist.albums??[]).length == 0) {
|
||||
this.artist = await deezerAPI.artist(artist.id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: FutureBuilder(
|
||||
future: _loadArtist(),
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
//Error / not done
|
||||
if (snapshot.hasError) return ErrorScreen();
|
||||
if (snapshot.connectionState != ConnectionState.done) return Center(child: CircularProgressIndicator(),);
|
||||
|
||||
return ListView(
|
||||
children: <Widget>[
|
||||
Card(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
CachedImage(
|
||||
url: artist.picture.full,
|
||||
height: 200,
|
||||
),
|
||||
Container(
|
||||
width: 200.0,
|
||||
height: 220,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
artist.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 4,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24.0, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Container(
|
||||
height: 8.0,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.people,
|
||||
size: 32.0,
|
||||
),
|
||||
Container(
|
||||
width: 8,
|
||||
),
|
||||
Text(
|
||||
artist.fansString,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
height: 4.0,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(Icons.album, size: 32.0),
|
||||
Container(
|
||||
width: 8.0,
|
||||
),
|
||||
Text(
|
||||
artist.albumCount.toString(),
|
||||
style: TextStyle(fontSize: 16),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(height: 4.0,),
|
||||
Card(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: <Widget>[
|
||||
FlatButton(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.favorite, size: 32),
|
||||
Container(width: 4,),
|
||||
Text('Library')
|
||||
],
|
||||
),
|
||||
onPressed: () async {
|
||||
await deezerAPI.addFavoriteArtist(artist.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Added to library',
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(height: 16.0,),
|
||||
//Top tracks
|
||||
Text(
|
||||
'Top Tracks',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22.0
|
||||
),
|
||||
),
|
||||
Container(height: 4.0),
|
||||
...List.generate(5, (i) {
|
||||
if (artist.topTracks.length <= i) return Container(height: 0, width: 0,);
|
||||
Track t = artist.topTracks[i];
|
||||
return TrackTile(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTopTracks(
|
||||
artist.topTracks,
|
||||
t.id,
|
||||
artist
|
||||
);
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet mi = MenuSheet(context);
|
||||
mi.defaultTrackMenu(t);
|
||||
},
|
||||
);
|
||||
}),
|
||||
ListTile(
|
||||
title: Text('Show more tracks'),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => TrackListScreen(artist.topTracks, QueueSource(
|
||||
id: artist.id,
|
||||
text: 'Top ${artist.name}',
|
||||
source: 'topTracks'
|
||||
)))
|
||||
);
|
||||
}
|
||||
),
|
||||
Divider(),
|
||||
//Albums
|
||||
Text(
|
||||
'Top Albums',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 22.0
|
||||
),
|
||||
),
|
||||
...List.generate(artist.albums.length, (i) {
|
||||
Album a = artist.albums[i];
|
||||
return AlbumTile(
|
||||
a,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => AlbumDetails(a))
|
||||
);
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(
|
||||
a
|
||||
);
|
||||
},
|
||||
);
|
||||
})
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PlaylistDetails extends StatefulWidget {
|
||||
|
||||
Playlist playlist;
|
||||
PlaylistDetails(this.playlist, {Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_PlaylistDetailsState createState() => _PlaylistDetailsState();
|
||||
}
|
||||
|
||||
class _PlaylistDetailsState extends State<PlaylistDetails> {
|
||||
|
||||
Playlist playlist;
|
||||
bool _loading = false;
|
||||
bool _error = false;
|
||||
ScrollController _scrollController = ScrollController();
|
||||
|
||||
//Load tracks from api
|
||||
void _load() async {
|
||||
if (playlist.tracks.length < playlist.trackCount && !_loading) {
|
||||
setState(() => _loading = true);
|
||||
int pos = playlist.tracks.length;
|
||||
//Get another page of tracks
|
||||
List<Track> tracks;
|
||||
try {
|
||||
tracks = await deezerAPI.playlistTracksPage(playlist.id, pos);
|
||||
} catch (e) {
|
||||
setState(() => _error = true);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
playlist.tracks.addAll(tracks);
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
playlist = widget.playlist;
|
||||
//If scrolled past 90% load next tracks
|
||||
_scrollController.addListener(() {
|
||||
double off = _scrollController.position.maxScrollExtent * 0.90;
|
||||
if (_scrollController.position.pixels > off) {
|
||||
_load();
|
||||
}
|
||||
});
|
||||
//Load if no tracks
|
||||
if (playlist.tracks.length == 0) {
|
||||
//Get correct metadata
|
||||
deezerAPI.playlist(playlist.id)
|
||||
.catchError((e) => setState(() => _error = true))
|
||||
.then((Playlist p) {
|
||||
if (p == null) return;
|
||||
setState(() {
|
||||
playlist = p;
|
||||
});
|
||||
//Load tracks
|
||||
_load();
|
||||
});
|
||||
}
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
controller: _scrollController,
|
||||
children: <Widget>[
|
||||
Container(height: 4.0,),
|
||||
Card(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: <Widget>[
|
||||
CachedImage(
|
||||
url: playlist.image.full,
|
||||
height: 180.0,
|
||||
),
|
||||
Container(
|
||||
width: 180,
|
||||
height: 200, //Card padding
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
playlist.title,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
style: TextStyle(
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
Text(
|
||||
playlist.user.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 18.0
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 8.0,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.audiotrack,
|
||||
size: 32.0,
|
||||
),
|
||||
Container(width: 8.0,),
|
||||
Text(playlist.trackCount.toString(), style: TextStyle(fontSize: 16),)
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.timelapse,
|
||||
size: 32.0,
|
||||
),
|
||||
Container(width: 8.0,),
|
||||
Text(playlist.durationString, style: TextStyle(fontSize: 16),)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(height: 4.0,),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(4.0),
|
||||
child: Text(
|
||||
playlist.description ?? '',
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16.0
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
Card(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: <Widget>[
|
||||
FlatButton(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.favorite, size: 32),
|
||||
Container(width: 4,),
|
||||
Text('Library')
|
||||
],
|
||||
),
|
||||
onPressed: () async {
|
||||
await deezerAPI.addFavoriteAlbum(playlist.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Added to library',
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
},
|
||||
),
|
||||
MakePlaylistOffline(playlist),
|
||||
FlatButton(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.file_download, size: 32.0,),
|
||||
Container(width: 4,),
|
||||
Text('Download')
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
downloadManager.addOfflinePlaylist(playlist, private: false);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
...List.generate(playlist.tracks.length, (i) {
|
||||
Track t = playlist.tracks[i];
|
||||
return TrackTile(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromPlaylist(playlist, t.id);
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t, options: [
|
||||
(playlist.user.id == deezerAPI.userId) ? m.removeFromPlaylist(t, playlist) : Container(width: 0, height: 0,)
|
||||
]);
|
||||
}
|
||||
);
|
||||
}),
|
||||
if (_loading)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
CircularProgressIndicator()
|
||||
],
|
||||
),
|
||||
if (_error)
|
||||
ErrorScreen()
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MakePlaylistOffline extends StatefulWidget {
|
||||
Playlist playlist;
|
||||
MakePlaylistOffline(this.playlist, {Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_MakePlaylistOfflineState createState() => _MakePlaylistOfflineState();
|
||||
}
|
||||
|
||||
class _MakePlaylistOfflineState extends State<MakePlaylistOffline> {
|
||||
bool _offline = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
downloadManager.checkOffline(playlist: widget.playlist).then((v) {
|
||||
setState(() {
|
||||
_offline = v;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
Switch(
|
||||
value: _offline,
|
||||
onChanged: (v) async {
|
||||
if (v) {
|
||||
//Add to offline
|
||||
if (widget.playlist.user != null && widget.playlist.user.id != deezerAPI.userId)
|
||||
await deezerAPI.addPlaylist(widget.playlist.id);
|
||||
downloadManager.addOfflinePlaylist(widget.playlist, private: true);
|
||||
setState(() {
|
||||
_offline = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
downloadManager.removeOfflinePlaylist(widget.playlist.id);
|
||||
setState(() {
|
||||
_offline = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Container(width: 4.0,),
|
||||
Text(
|
||||
'Offline',
|
||||
style: TextStyle(fontSize: 16),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
113
lib/ui/downloads_screen.dart
Normal file
113
lib/ui/downloads_screen.dart
Normal file
|
@ -0,0 +1,113 @@
|
|||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'cached_image.dart';
|
||||
import '../api/download.dart';
|
||||
|
||||
|
||||
class DownloadTile extends StatelessWidget {
|
||||
|
||||
final Download download;
|
||||
DownloadTile(this.download);
|
||||
|
||||
String get subtitle {
|
||||
switch (download.state) {
|
||||
case DownloadState.NONE: return '';
|
||||
case DownloadState.DOWNLOADING:
|
||||
return '${filesize(download.received)} / ${filesize(download.total)}';
|
||||
case DownloadState.POST:
|
||||
return 'Post processing...';
|
||||
case DownloadState.DONE:
|
||||
return 'Done'; //Shouldn't be visible
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
Widget get progressBar {
|
||||
switch (download.state) {
|
||||
case DownloadState.DOWNLOADING:
|
||||
return LinearProgressIndicator(value: download.received / download.total);
|
||||
case DownloadState.POST:
|
||||
return LinearProgressIndicator();
|
||||
default:
|
||||
return Container(height: 0, width: 0,);
|
||||
}
|
||||
}
|
||||
|
||||
Widget get trailing {
|
||||
if (download.private) {
|
||||
return Icon(Icons.offline_pin);
|
||||
}
|
||||
return Icon(Icons.sd_card);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text(download.track.title),
|
||||
subtitle: Text(subtitle),
|
||||
leading: CachedImage(
|
||||
url: download.track.albumArt.thumb,
|
||||
),
|
||||
trailing: trailing,
|
||||
),
|
||||
progressBar
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadsScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Downloads'),
|
||||
),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
StreamBuilder(
|
||||
stream: Stream.periodic(Duration(milliseconds: 500)), //Periodic to get current download progress
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
|
||||
if (downloadManager.queue.length == 0)
|
||||
return Container(width: 0, height: 0,);
|
||||
|
||||
return Column(
|
||||
children: List.generate(downloadManager.queue.length, (i) {
|
||||
return DownloadTile(downloadManager.queue[i]);
|
||||
})
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder(
|
||||
future: downloadManager.getFinishedDownloads(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data.length == 0) return Container(height: 0, width: 0,);
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Divider(),
|
||||
Text(
|
||||
'History',
|
||||
style: TextStyle(
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
...List.generate(snapshot.data.length, (i) {
|
||||
Download d = snapshot.data[i];
|
||||
return DownloadTile(d);
|
||||
})
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
26
lib/ui/error.dart
Normal file
26
lib/ui/error.dart
Normal file
|
@ -0,0 +1,26 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class ErrorScreen extends StatelessWidget {
|
||||
|
||||
final String message;
|
||||
|
||||
ErrorScreen({this.message});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.error,
|
||||
color: Colors.red,
|
||||
size: 64.0,
|
||||
),
|
||||
Container(height: 4.0,),
|
||||
Text(message ?? 'Please check your connection and try again later...')
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
224
lib/ui/home_screen.dart
Normal file
224
lib/ui/home_screen.dart
Normal file
|
@ -0,0 +1,224 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/ui/error.dart';
|
||||
import 'package:freezer/ui/menu.dart';
|
||||
import 'tiles.dart';
|
||||
import 'details_screens.dart';
|
||||
import '../settings.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
children: <Widget>[
|
||||
Container(height: 16.0,),
|
||||
FreezerTitle(),
|
||||
Container(height: 16.0,),
|
||||
HomePageScreen()
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FreezerTitle extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
'freezer',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Jost',
|
||||
fontSize: 75,
|
||||
fontStyle: FontStyle.italic,
|
||||
letterSpacing: 7
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class HomePageScreen extends StatefulWidget {
|
||||
|
||||
final HomePage homePage;
|
||||
final DeezerChannel channel;
|
||||
HomePageScreen({this.homePage, this.channel, Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_HomePageScreenState createState() => _HomePageScreenState();
|
||||
}
|
||||
|
||||
class _HomePageScreenState extends State<HomePageScreen> {
|
||||
|
||||
HomePage _homePage;
|
||||
bool _cancel = false;
|
||||
bool _error = false;
|
||||
|
||||
void _loadChannel() async {
|
||||
HomePage _hp;
|
||||
//Fetch channel from api
|
||||
try {
|
||||
_hp = await deezerAPI.getChannel(widget.channel.target);
|
||||
} catch (e) {}
|
||||
if (_hp == null) {
|
||||
//On error
|
||||
setState(() => _error = true);
|
||||
return;
|
||||
}
|
||||
setState(() => _homePage = _hp);
|
||||
}
|
||||
void _loadHomePage() async {
|
||||
//Load local
|
||||
try {
|
||||
HomePage _hp = await HomePage().load();
|
||||
setState(() => _homePage = _hp);
|
||||
} catch (e) {}
|
||||
//On background load from API
|
||||
try {
|
||||
if (settings.offlineMode) return;
|
||||
HomePage _hp = await deezerAPI.homePage();
|
||||
if (_hp != null) {
|
||||
if (_cancel) return;
|
||||
if (_hp.sections.length == 0) return;
|
||||
setState(() => _homePage = _hp);
|
||||
//Save to cache
|
||||
await _homePage.save();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
void _load() {
|
||||
if (widget.channel != null) {
|
||||
_loadChannel();
|
||||
return;
|
||||
}
|
||||
if (widget.channel == null && widget.homePage == null) {
|
||||
_loadHomePage();
|
||||
return;
|
||||
}
|
||||
if (widget.homePage.sections == null || widget.homePage.sections.length == 0) {
|
||||
_loadHomePage();
|
||||
return;
|
||||
}
|
||||
//Already have data
|
||||
setState(() => _homePage = widget.homePage);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_load();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancel = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_homePage == null)
|
||||
return Center(child: CircularProgressIndicator(),);
|
||||
if (_error)
|
||||
return ErrorScreen();
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
...List.generate(_homePage.sections.length, (i) {
|
||||
HomePageSection section = _homePage.sections[i];
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
child: Text(
|
||||
section.title,
|
||||
textAlign: TextAlign.left,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 24.0),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0)
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List<Widget>.generate(section.items.length, (i) {
|
||||
HomePageItem item = section.items[i];
|
||||
|
||||
switch (item.type) {
|
||||
case HomePageItemType.SMARTTRACKLIST:
|
||||
return SmartTrackListTile(
|
||||
item.value,
|
||||
onTap: () {
|
||||
playerHelper.playFromSmartTrackList(item.value);
|
||||
},
|
||||
);
|
||||
case HomePageItemType.ALBUM:
|
||||
return AlbumCard(
|
||||
item.value,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => AlbumDetails(item.value)
|
||||
));
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(item.value);
|
||||
},
|
||||
);
|
||||
case HomePageItemType.ARTIST:
|
||||
return ArtistTile(
|
||||
item.value,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => ArtistDetails(item.value)
|
||||
));
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultArtistMenu(item.value);
|
||||
},
|
||||
);
|
||||
case HomePageItemType.PLAYLIST:
|
||||
return PlaylistCardTile(
|
||||
item.value,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetails(item.value)
|
||||
));
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultPlaylistMenu(item.value);
|
||||
},
|
||||
);
|
||||
case HomePageItemType.CHANNEL:
|
||||
return ChannelTile(
|
||||
item.value,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
appBar: AppBar(title: Text(item.value.title.toString()),),
|
||||
body: HomePageScreen(channel: item.value,),
|
||||
)
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
return Container(height: 0, width: 0);
|
||||
}),
|
||||
),
|
||||
),
|
||||
Container(height: 16.0,)
|
||||
],
|
||||
);
|
||||
})
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
610
lib/ui/library.dart
Normal file
610
lib/ui/library.dart
Normal file
|
@ -0,0 +1,610 @@
|
|||
import 'package:connectivity/connectivity.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/definitions.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/settings.dart';
|
||||
import 'package:freezer/ui/details_screens.dart';
|
||||
import 'package:freezer/ui/downloads_screen.dart';
|
||||
import 'package:freezer/ui/error.dart';
|
||||
import 'package:freezer/ui/tiles.dart';
|
||||
|
||||
import 'menu.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'player_bar.dart';
|
||||
import '../api/download.dart';
|
||||
|
||||
class LibraryAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
|
||||
@override
|
||||
Size get preferredSize => AppBar().preferredSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
title: Text('Library'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.file_download),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => DownloadsScreen())
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.settings),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => SettingsScreen())
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class LibraryScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: LibraryAppBar(),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Container(height: 4.0,),
|
||||
if (downloadManager.stopped)
|
||||
ListTile(
|
||||
title: Text('Downloads'),
|
||||
leading: Icon(Icons.file_download),
|
||||
subtitle: Text('Downloading is currently stopped, click here to resume.'),
|
||||
onTap: () {
|
||||
downloadManager.updateQueue();
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => DownloadsScreen()
|
||||
));
|
||||
},
|
||||
),
|
||||
//Dirty if to not use columns
|
||||
if (downloadManager.stopped)
|
||||
Divider(),
|
||||
|
||||
ListTile(
|
||||
title: Text('Tracks'),
|
||||
leading: Icon(Icons.audiotrack),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => LibraryTracks())
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Albums'),
|
||||
leading: Icon(Icons.album),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => LibraryAlbums())
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Artists'),
|
||||
leading: Icon(Icons.recent_actors),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => LibraryArtists())
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Playlists'),
|
||||
leading: Icon(Icons.playlist_play),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => LibraryPlaylists())
|
||||
);
|
||||
},
|
||||
),
|
||||
ExpansionTile(
|
||||
title: Text('Statistics'),
|
||||
leading: Icon(Icons.insert_chart),
|
||||
children: <Widget>[
|
||||
FutureBuilder(
|
||||
future: downloadManager.getStats(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return ErrorScreen();
|
||||
if (!snapshot.hasData) return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
CircularProgressIndicator()
|
||||
],
|
||||
),
|
||||
);
|
||||
List<String> data = snapshot.data;
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('Offline tracks'),
|
||||
leading: Icon(Icons.audiotrack),
|
||||
trailing: Text(data[0]),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Offline albums'),
|
||||
leading: Icon(Icons.album),
|
||||
trailing: Text(data[1]),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Offline playlists'),
|
||||
leading: Icon(Icons.playlist_add),
|
||||
trailing: Text(data[2]),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Offline size'),
|
||||
leading: Icon(Icons.sd_card),
|
||||
trailing: Text(data[3]),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Free space'),
|
||||
leading: Icon(Icons.disc_full),
|
||||
trailing: Text(data[4]),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LibraryTracks extends StatefulWidget {
|
||||
@override
|
||||
_LibraryTracksState createState() => _LibraryTracksState();
|
||||
}
|
||||
|
||||
class _LibraryTracksState extends State<LibraryTracks> {
|
||||
|
||||
bool _loading = false;
|
||||
ScrollController _scrollController = ScrollController();
|
||||
List<Track> tracks = [];
|
||||
List<Track> allTracks = [];
|
||||
|
||||
Playlist get _playlist => Playlist(id: deezerAPI.favoritesPlaylistId);
|
||||
|
||||
Future _load() async {
|
||||
ConnectivityResult connectivity = await Connectivity().checkConnectivity();
|
||||
if (connectivity != ConnectivityResult.none) {
|
||||
setState(() => _loading = true);
|
||||
int pos = tracks.length;
|
||||
//Load another page of tracks from deezer
|
||||
List<Track> _t;
|
||||
try {
|
||||
_t = await deezerAPI.playlistTracksPage(deezerAPI.favoritesPlaylistId, pos);
|
||||
} catch (e) {}
|
||||
//On error load offline
|
||||
if (_t == null) {
|
||||
await _loadOffline();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
tracks.addAll(_t);
|
||||
_loading = false;
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Future _loadOffline() async {
|
||||
Playlist p = await downloadManager.getPlaylist(deezerAPI.favoritesPlaylistId);
|
||||
if (p != null) setState(() {
|
||||
tracks = p.tracks;
|
||||
});
|
||||
}
|
||||
|
||||
Future _loadAll() async {
|
||||
List tracks = await downloadManager.allOfflineTracks();
|
||||
setState(() {
|
||||
allTracks = tracks;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_scrollController.addListener(() {
|
||||
//Load more tracks on scroll
|
||||
double off = _scrollController.position.maxScrollExtent * 0.90;
|
||||
if (_scrollController.position.pixels > off) _load();
|
||||
});
|
||||
|
||||
_load();
|
||||
|
||||
//Load all tracks
|
||||
_loadAll();
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Tracks'),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Card(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(height: 8.0,),
|
||||
Text(
|
||||
'Loved tracks',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 24
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: <Widget>[
|
||||
MakePlaylistOffline(_playlist),
|
||||
FlatButton(
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.file_download, size: 32.0,),
|
||||
Container(width: 4,),
|
||||
Text('Download')
|
||||
],
|
||||
),
|
||||
onPressed: () {
|
||||
downloadManager.addOfflinePlaylist(_playlist, private: false);
|
||||
},
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
//Loved tracks
|
||||
...List.generate(tracks.length, (i) {
|
||||
Track t = tracks[i];
|
||||
return TrackTile(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTrackList(tracks, t.id, QueueSource(
|
||||
id: deezerAPI.favoritesPlaylistId,
|
||||
text: 'Favorites',
|
||||
source: 'playlist'
|
||||
));
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(
|
||||
t,
|
||||
onRemove: () {
|
||||
setState(() {
|
||||
tracks.removeWhere((track) => t.id == track.id);
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
if (_loading)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
],
|
||||
),
|
||||
Divider(),
|
||||
Text(
|
||||
'All offline tracks',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
Container(height: 8,),
|
||||
...List.generate(allTracks.length, (i) {
|
||||
Track t = allTracks[i];
|
||||
return TrackTile(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTrackList(allTracks, t.id, QueueSource(
|
||||
id: 'allTracks',
|
||||
text: 'All offline tracks',
|
||||
source: 'offline'
|
||||
));
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t);
|
||||
},
|
||||
);
|
||||
})
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LibraryAlbums extends StatefulWidget {
|
||||
@override
|
||||
_LibraryAlbumsState createState() => _LibraryAlbumsState();
|
||||
}
|
||||
|
||||
class _LibraryAlbumsState extends State<LibraryAlbums> {
|
||||
|
||||
List<Album> _albums;
|
||||
|
||||
Future _load() async {
|
||||
if (settings.offlineMode) return;
|
||||
try {
|
||||
List<Album> albums = await deezerAPI.getAlbums();
|
||||
setState(() => _albums = albums);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_load();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Albums'),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Container(height: 8.0,),
|
||||
if (!settings.offlineMode && _albums == null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
CircularProgressIndicator()
|
||||
],
|
||||
),
|
||||
|
||||
if (_albums != null)
|
||||
...List.generate(_albums.length, (int i) {
|
||||
Album a = _albums[i];
|
||||
return AlbumTile(
|
||||
a,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => AlbumDetails(a))
|
||||
);
|
||||
},
|
||||
onHold: () async {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(a, onRemove: () {
|
||||
setState(() => _albums.remove(a));
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
FutureBuilder(
|
||||
future: downloadManager.getOfflineAlbums(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError || !snapshot.hasData || snapshot.data.length == 0) return Container(height: 0, width: 0,);
|
||||
|
||||
List<Album> albums = snapshot.data;
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Divider(),
|
||||
Text(
|
||||
'Offline albums',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 24.0
|
||||
),
|
||||
),
|
||||
...List.generate(albums.length, (i) {
|
||||
Album a = albums[i];
|
||||
return AlbumTile(
|
||||
a,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => AlbumDetails(a))
|
||||
);
|
||||
},
|
||||
onHold: () async {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(a, onRemove: () {
|
||||
setState(() {
|
||||
albums.remove(a);
|
||||
_albums.remove(a);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
})
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LibraryArtists extends StatefulWidget {
|
||||
@override
|
||||
_LibraryArtistsState createState() => _LibraryArtistsState();
|
||||
}
|
||||
|
||||
class _LibraryArtistsState extends State<LibraryArtists> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Artists'),),
|
||||
body: FutureBuilder(
|
||||
future: deezerAPI.getArtists(),
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
|
||||
if (snapshot.hasError) return ErrorScreen();
|
||||
if (!snapshot.hasData) return Center(child: CircularProgressIndicator(),);
|
||||
|
||||
return ListView(
|
||||
children: <Widget>[
|
||||
...List.generate(snapshot.data.length, (i) {
|
||||
Artist a = snapshot.data[i];
|
||||
return ArtistHorizontalTile(
|
||||
a,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => ArtistDetails(a))
|
||||
);
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultArtistMenu(a, onRemove: () {
|
||||
setState(() => {});
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LibraryPlaylists extends StatefulWidget {
|
||||
@override
|
||||
_LibraryPlaylistsState createState() => _LibraryPlaylistsState();
|
||||
}
|
||||
|
||||
class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
||||
|
||||
List<Playlist> _playlists;
|
||||
|
||||
Future _load() async {
|
||||
if (!settings.offlineMode) {
|
||||
try {
|
||||
List<Playlist> playlists = await deezerAPI.getPlaylists();
|
||||
setState(() => _playlists = playlists);
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_load();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Playlists'),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('Create new playlist'),
|
||||
leading: Icon(Icons.playlist_add),
|
||||
onTap: () {
|
||||
if (settings.offlineMode) {
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Cannot create playlists in offline mode',
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
return;
|
||||
}
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.createPlaylist();
|
||||
},
|
||||
),
|
||||
Divider(),
|
||||
|
||||
if (!settings.offlineMode && _playlists == null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
CircularProgressIndicator(),
|
||||
],
|
||||
),
|
||||
|
||||
if (_playlists != null)
|
||||
...List.generate(_playlists.length, (int i) {
|
||||
Playlist p = _playlists[i];
|
||||
return PlaylistTile(
|
||||
p,
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetails(p)
|
||||
)),
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultPlaylistMenu(p, onRemove: () {
|
||||
setState(() => _playlists.remove(p));
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
FutureBuilder(
|
||||
future: downloadManager.getOfflinePlaylists(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError || !snapshot.hasData) return Container(height: 0, width: 0,);
|
||||
if (snapshot.data.length == 0) return Container(height: 0, width: 0,);
|
||||
|
||||
List<Playlist> playlists = snapshot.data;
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Divider(),
|
||||
Text(
|
||||
'Offline playlists',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
...List.generate(playlists.length, (i) {
|
||||
Playlist p = playlists[i];
|
||||
return PlaylistTile(
|
||||
p,
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => PlaylistDetails(p)
|
||||
)),
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultPlaylistMenu(p, onRemove: () {
|
||||
setState(() {
|
||||
playlists.remove(p);
|
||||
_playlists.remove(p);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
})
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
254
lib/ui/login_screen.dart
Normal file
254
lib/ui/login_screen.dart
Normal file
|
@ -0,0 +1,254 @@
|
|||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/main.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
import '../settings.dart';
|
||||
import '../api/definitions.dart';
|
||||
import 'home_screen.dart';
|
||||
|
||||
class LoginWidget extends StatefulWidget {
|
||||
|
||||
Function callback;
|
||||
LoginWidget({this.callback, Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_LoginWidgetState createState() => _LoginWidgetState();
|
||||
}
|
||||
|
||||
class _LoginWidgetState extends State<LoginWidget> {
|
||||
|
||||
String _arl;
|
||||
|
||||
//Initialize deezer etc
|
||||
Future _init() async {
|
||||
deezerAPI.arl = settings.arl;
|
||||
await playerHelper.start();
|
||||
|
||||
//Pre-cache homepage
|
||||
if (!await HomePage().exists()) {
|
||||
await deezerAPI.authorize();
|
||||
settings.offlineMode = false;
|
||||
HomePage hp = await deezerAPI.homePage();
|
||||
await hp.save();
|
||||
}
|
||||
}
|
||||
//Call _init()
|
||||
void _start() async {
|
||||
if (settings.arl != null) {
|
||||
_init().then((_) {
|
||||
if (widget.callback != null) widget.callback();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LoginWidget oldWidget) {
|
||||
_start();
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_start();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void errorDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Error'),
|
||||
content: Text('Error logging in! Please check your token and internet connection and try again.'),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('Dismiss'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
void _update() async {
|
||||
setState(() => {});
|
||||
|
||||
//Try logging in
|
||||
try {
|
||||
deezerAPI.arl = settings.arl;
|
||||
bool resp = await deezerAPI.authorize();
|
||||
if (resp == false) { //false, not null
|
||||
setState(() => settings.arl = null);
|
||||
errorDialog();
|
||||
}
|
||||
//On error show dialog and reset to null
|
||||
} catch (e) {
|
||||
setState(() => settings.arl = null);
|
||||
errorDialog();
|
||||
}
|
||||
|
||||
await settings.save();
|
||||
_start();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
//If arl non null, show loading
|
||||
if (settings.arl != null)
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
|
||||
if (settings.arl == null)
|
||||
return Scaffold(
|
||||
body: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: ListView(
|
||||
children: <Widget>[
|
||||
Container(height: 16.0,),
|
||||
Text(
|
||||
'Welcome to',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16.0
|
||||
),
|
||||
),
|
||||
FreezerTitle(),
|
||||
Container(height: 8.0,),
|
||||
Text(
|
||||
"Please login using your Deezer account.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18.0
|
||||
),
|
||||
),
|
||||
Container(height: 16.0,),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32.0),
|
||||
child: OutlineButton(
|
||||
child: Text('Login using browser'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => LoginBrowser(_update))
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32.0),
|
||||
child: OutlineButton(
|
||||
child: Text('Login using token'),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Enter ARL'),
|
||||
content: Container(
|
||||
child: TextField(
|
||||
onChanged: (String s) => _arl = s,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Token (ARL)'
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('Save'),
|
||||
onPressed: () {
|
||||
settings.arl = _arl;
|
||||
Navigator.of(context).pop();
|
||||
_update();
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(height: 16.0,),
|
||||
Text(
|
||||
"If you don't have account, you can register on deezer.com for free.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18.0
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32.0),
|
||||
child: OutlineButton(
|
||||
child: Text('Open in browser'),
|
||||
onPressed: () {
|
||||
InAppBrowser.openWithSystemBrowser(url: 'https://deezer.com/register');
|
||||
},
|
||||
),
|
||||
),
|
||||
Container(height: 8.0,),
|
||||
Divider(),
|
||||
Container(height: 8.0,),
|
||||
Text(
|
||||
"By using this app, you don't agree with the Deezer ToS",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 18.0
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LoginBrowser extends StatelessWidget {
|
||||
|
||||
Function updateParent;
|
||||
LoginBrowser(this.updateParent);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Container(
|
||||
|
||||
child: InAppWebView(
|
||||
initialUrl: 'https://deezer.com/login',
|
||||
onLoadStart: (InAppWebViewController controller, String url) async {
|
||||
//Parse arl from url
|
||||
if (url.startsWith('intent://deezer.page.link')) {
|
||||
try {
|
||||
//Parse url
|
||||
Uri uri = Uri.parse(url);
|
||||
//Actual url is in `link` query parameter
|
||||
Uri linkUri = Uri.parse(uri.queryParameters['link']);
|
||||
String arl = linkUri.queryParameters['arl'];
|
||||
if (arl != null) {
|
||||
settings.arl = arl;
|
||||
Navigator.of(context).pop();
|
||||
updateParent();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
615
lib/ui/menu.dart
Normal file
615
lib/ui/menu.dart
Normal file
|
@ -0,0 +1,615 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/ui/details_screens.dart';
|
||||
import 'package:freezer/ui/error.dart';
|
||||
|
||||
import '../api/definitions.dart';
|
||||
import '../api/player.dart';
|
||||
import 'cached_image.dart';
|
||||
|
||||
class MenuSheet {
|
||||
|
||||
BuildContext context;
|
||||
|
||||
MenuSheet(this.context);
|
||||
|
||||
//===================
|
||||
// DEFAULT
|
||||
//===================
|
||||
|
||||
void show(List<Widget> options) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: (MediaQuery.of(context).orientation == Orientation.landscape)?220:350,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: options
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
//===================
|
||||
// TRACK
|
||||
//===================
|
||||
|
||||
void showWithTrack(Track track, List<Widget> options) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(height: 16.0,),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: <Widget>[
|
||||
CachedImage(
|
||||
url: track.albumArt.full,
|
||||
height: 128,
|
||||
width: 128,
|
||||
),
|
||||
Container(
|
||||
width: 240.0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
track.title,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 22.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
Text(
|
||||
track.artistString,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 20.0
|
||||
),
|
||||
),
|
||||
Container(height: 8.0,),
|
||||
Text(
|
||||
track.album.title,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
Text(
|
||||
track.durationString
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(height: 16.0,),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: (MediaQuery.of(context).orientation == Orientation.landscape)?220:350,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: options
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
//Default track options
|
||||
void defaultTrackMenu(Track track, {List<Widget> options = const [], Function onRemove}) {
|
||||
showWithTrack(track, [
|
||||
addToQueueNext(track),
|
||||
addToQueue(track),
|
||||
(track.favorite??false)?removeFavoriteTrack(track, onUpdate: onRemove):addTrackFavorite(track),
|
||||
addToPlaylist(track),
|
||||
downloadTrack(track),
|
||||
showAlbum(track.album),
|
||||
...List.generate(track.artists.length, (i) => showArtist(track.artists[i])),
|
||||
...options
|
||||
]);
|
||||
}
|
||||
|
||||
//===================
|
||||
// TRACK OPTIONS
|
||||
//===================
|
||||
|
||||
Widget addToQueueNext(Track t) => ListTile(
|
||||
title: Text('Play next'),
|
||||
leading: Icon(Icons.playlist_play),
|
||||
onTap: () async {
|
||||
if (playerHelper.queueIndex == -1) {
|
||||
//First track
|
||||
await AudioService.addQueueItem(t.toMediaItem());
|
||||
await AudioService.play();
|
||||
} else {
|
||||
//Normal
|
||||
await AudioService.addQueueItemAt(
|
||||
t.toMediaItem(), playerHelper.queueIndex + 1);
|
||||
}
|
||||
_close();
|
||||
});
|
||||
|
||||
Widget addToQueue(Track t) => ListTile(
|
||||
title: Text('Add to queue'),
|
||||
leading: Icon(Icons.playlist_add),
|
||||
onTap: () async {
|
||||
await AudioService.addQueueItem(t.toMediaItem());
|
||||
_close();
|
||||
}
|
||||
);
|
||||
|
||||
Widget addTrackFavorite(Track t) => ListTile(
|
||||
title: Text('Add track to favorites'),
|
||||
leading: Icon(Icons.favorite),
|
||||
onTap: () async {
|
||||
await deezerAPI.addFavoriteTrack(t.id);
|
||||
//Make track offline, if favorites are offline
|
||||
Playlist p = Playlist(id: deezerAPI.favoritesPlaylistId);
|
||||
if (await downloadManager.checkOffline(playlist: p)) {
|
||||
downloadManager.addOfflinePlaylist(p);
|
||||
}
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Added to library!',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastLength: Toast.LENGTH_SHORT
|
||||
);
|
||||
_close();
|
||||
}
|
||||
);
|
||||
|
||||
Widget downloadTrack(Track t) => ListTile(
|
||||
title: Text('Download'),
|
||||
leading: Icon(Icons.file_download),
|
||||
onTap: () async {
|
||||
await downloadManager.addOfflineTrack(t, private: false);
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
Widget addToPlaylist(Track t) => ListTile(
|
||||
title: Text('Add to playlist'),
|
||||
leading: Icon(Icons.playlist_add),
|
||||
onTap: () async {
|
||||
|
||||
Playlist p;
|
||||
|
||||
//Show dialog to pick playlist
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Select playlist'),
|
||||
content: FutureBuilder(
|
||||
future: deezerAPI.getPlaylists(),
|
||||
builder: (context, snapshot) {
|
||||
|
||||
if (snapshot.hasError) SizedBox(
|
||||
height: 100,
|
||||
child: ErrorScreen(),
|
||||
);
|
||||
if (snapshot.connectionState != ConnectionState.done) return SizedBox(
|
||||
height: 100,
|
||||
child: Center(child: CircularProgressIndicator(),),
|
||||
);
|
||||
|
||||
List<Playlist> playlists = snapshot.data;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...List.generate(playlists.length, (i) => ListTile(
|
||||
title: Text(playlists[i].title),
|
||||
leading: CachedImage(
|
||||
url: playlists[i].image.thumb,
|
||||
),
|
||||
onTap: () {
|
||||
p = playlists[i];
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)),
|
||||
ListTile(
|
||||
title: Text('Create new playlist'),
|
||||
leading: Icon(Icons.add),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => CreatePlaylistDialog(tracks: [t],)
|
||||
);
|
||||
},
|
||||
)
|
||||
]
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
//Add to playlist, show toast
|
||||
if (p != null) {
|
||||
await deezerAPI.addToPlaylist(t.id, p.id);
|
||||
//Update the playlist if offline
|
||||
if (await downloadManager.checkOffline(playlist: p)) {
|
||||
downloadManager.addOfflinePlaylist(p);
|
||||
}
|
||||
Fluttertoast.showToast(
|
||||
msg: "Track added to ${p.title}",
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
Widget removeFromPlaylist(Track t, Playlist p) => ListTile(
|
||||
title: Text('Remove from playlist'),
|
||||
leading: Icon(Icons.delete),
|
||||
onTap: () async {
|
||||
await deezerAPI.removeFromPlaylist(t.id, p.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Track removed from ${p.title}',
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
Widget removeFavoriteTrack(Track t, {onUpdate}) => ListTile(
|
||||
title: Text('Remove favorite'),
|
||||
leading: Icon(Icons.delete),
|
||||
onTap: () async {
|
||||
await deezerAPI.removeFavorite(t.id);
|
||||
//Check if favorites playlist is offline, update it
|
||||
Playlist p = Playlist(id: deezerAPI.favoritesPlaylistId);
|
||||
if (await downloadManager.checkOffline(playlist: p)) {
|
||||
await downloadManager.addOfflinePlaylist(p);
|
||||
}
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Track removed from library',
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
onUpdate();
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
//Redirect to artist page (ie from track)
|
||||
Widget showArtist(Artist a) => ListTile(
|
||||
title: Text(
|
||||
'Go to ${a.name}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
leading: Icon(Icons.recent_actors),
|
||||
onTap: () {
|
||||
_close();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => ArtistDetails(a))
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget showAlbum(Album a) => ListTile(
|
||||
title: Text(
|
||||
'Go to ${a.title}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
leading: Icon(Icons.album),
|
||||
onTap: () {
|
||||
_close();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => AlbumDetails(a))
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
//===================
|
||||
// ALBUM
|
||||
//===================
|
||||
|
||||
//Default album options
|
||||
void defaultAlbumMenu(Album album, {List<Widget> options = const [], Function onRemove}) {
|
||||
show([
|
||||
album.library?removeAlbum(album, onRemove: onRemove):libraryAlbum(album),
|
||||
downloadAlbum(album),
|
||||
offlineAlbum(album),
|
||||
...options
|
||||
]);
|
||||
}
|
||||
|
||||
//===================
|
||||
// ALBUM OPTIONS
|
||||
//===================
|
||||
|
||||
Widget downloadAlbum(Album a) => ListTile(
|
||||
title: Text('Download'),
|
||||
leading: Icon(Icons.file_download),
|
||||
onTap: () async {
|
||||
await downloadManager.addOfflineAlbum(a, private: false);
|
||||
_close();
|
||||
}
|
||||
);
|
||||
|
||||
Widget offlineAlbum(Album a) => ListTile(
|
||||
title: Text('Make offline'),
|
||||
leading: Icon(Icons.offline_pin),
|
||||
onTap: () async {
|
||||
await deezerAPI.addFavoriteAlbum(a.id);
|
||||
await downloadManager.addOfflineAlbum(a, private: true);
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
Widget libraryAlbum(Album a) => ListTile(
|
||||
title: Text('Add to library'),
|
||||
leading: Icon(Icons.library_music),
|
||||
onTap: () async {
|
||||
await deezerAPI.addFavoriteAlbum(a.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Added to library',
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
//Remove album from favorites
|
||||
Widget removeAlbum(Album a, {Function onRemove}) => ListTile(
|
||||
title: Text('Remove album'),
|
||||
leading: Icon(Icons.delete),
|
||||
onTap: () async {
|
||||
await deezerAPI.removeAlbum(a.id);
|
||||
await downloadManager.removeOfflineAlbum(a.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Album removed',
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
if (onRemove != null) onRemove();
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
//===================
|
||||
// ARTIST
|
||||
//===================
|
||||
|
||||
void defaultArtistMenu(Artist artist, {List<Widget> options = const [], Function onRemove}) {
|
||||
show([
|
||||
artist.library?removeArtist(artist, onRemove: onRemove):favoriteArtist(artist),
|
||||
...options
|
||||
]);
|
||||
}
|
||||
|
||||
//===================
|
||||
// ARTIST OPTIONS
|
||||
//===================
|
||||
|
||||
Widget removeArtist(Artist a, {Function onRemove}) => ListTile(
|
||||
title: Text('Remove from favorites'),
|
||||
leading: Icon(Icons.delete),
|
||||
onTap: () async {
|
||||
await deezerAPI.removeArtist(a.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Artist removed from library',
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
if (onRemove != null) onRemove();
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
Widget favoriteArtist(Artist a) => ListTile(
|
||||
title: Text('Add to favorites'),
|
||||
leading: Icon(Icons.favorite),
|
||||
onTap: () async {
|
||||
await deezerAPI.addFavoriteArtist(a.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Added to library',
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
//===================
|
||||
// PLAYLIST
|
||||
//===================
|
||||
|
||||
void defaultPlaylistMenu(Playlist playlist, {List<Widget> options = const [], Function onRemove}) {
|
||||
show([
|
||||
playlist.library?removePlaylistLibrary(playlist, onRemove: onRemove):addPlaylistLibrary(playlist),
|
||||
addPlaylistOffline(playlist),
|
||||
downloadPlaylist(playlist),
|
||||
...options
|
||||
]);
|
||||
}
|
||||
|
||||
//===================
|
||||
// PLAYLIST OPTIONS
|
||||
//===================
|
||||
|
||||
Widget removePlaylistLibrary(Playlist p, {Function onRemove}) => ListTile(
|
||||
title: Text('Remove from library'),
|
||||
leading: Icon(Icons.delete),
|
||||
onTap: () async {
|
||||
if (p.user.id.trim() == deezerAPI.userId) {
|
||||
//Delete playlist if own
|
||||
await deezerAPI.deletePlaylist(p.id);
|
||||
} else {
|
||||
//Just remove from library
|
||||
await deezerAPI.removePlaylist(p.id);
|
||||
}
|
||||
downloadManager.removeOfflinePlaylist(p.id);
|
||||
if (onRemove != null) onRemove();
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
Widget addPlaylistLibrary(Playlist p) => ListTile(
|
||||
title: Text('Add playlist to library'),
|
||||
leading: Icon(Icons.favorite),
|
||||
onTap: () async {
|
||||
await deezerAPI.addPlaylist(p.id);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Added playlist to library',
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
Widget addPlaylistOffline(Playlist p) => ListTile(
|
||||
title: Text('Make playlist offline'),
|
||||
leading: Icon(Icons.offline_pin),
|
||||
onTap: () async {
|
||||
//Add to library
|
||||
await deezerAPI.addPlaylist(p.id);
|
||||
downloadManager.addOfflinePlaylist(p, private: true);
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
Widget downloadPlaylist(Playlist p) => ListTile(
|
||||
title: Text('Download playlist'),
|
||||
leading: Icon(Icons.file_download),
|
||||
onTap: () async {
|
||||
downloadManager.addOfflinePlaylist(p, private: false);
|
||||
_close();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
//===================
|
||||
// OTHER
|
||||
//===================
|
||||
|
||||
//Create playlist
|
||||
void createPlaylist() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return CreatePlaylistDialog();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _close() => Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
|
||||
class CreatePlaylistDialog extends StatefulWidget {
|
||||
|
||||
final List<Track> tracks;
|
||||
CreatePlaylistDialog({this.tracks, Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_CreatePlaylistDialogState createState() => _CreatePlaylistDialogState();
|
||||
}
|
||||
|
||||
class _CreatePlaylistDialogState extends State<CreatePlaylistDialog> {
|
||||
|
||||
int _playlistType = 1;
|
||||
String _title = '';
|
||||
String _description = '';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('Create playlist'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Title'
|
||||
),
|
||||
onChanged: (String s) => _title = s,
|
||||
),
|
||||
TextField(
|
||||
onChanged: (String s) => _description = s,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Description'
|
||||
),
|
||||
),
|
||||
Container(height: 4.0,),
|
||||
DropdownButton<int>(
|
||||
value: _playlistType,
|
||||
onChanged: (int v) {
|
||||
setState(() => _playlistType = v);
|
||||
},
|
||||
items: [
|
||||
DropdownMenuItem<int>(
|
||||
value: 1,
|
||||
child: Text('Private'),
|
||||
),
|
||||
DropdownMenuItem<int>(
|
||||
value: 2,
|
||||
child: Text('Collaborative'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
FlatButton(
|
||||
child: Text('Cancel'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
FlatButton(
|
||||
child: Text('Create'),
|
||||
onPressed: () async {
|
||||
List<String> tracks = [];
|
||||
if (widget.tracks != null) {
|
||||
tracks = widget.tracks.map<String>((t) => t.id).toList();
|
||||
}
|
||||
await deezerAPI.createPlaylist(
|
||||
_title,
|
||||
status: _playlistType,
|
||||
description: _description,
|
||||
trackIds: tracks
|
||||
);
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Playlist created!',
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
160
lib/ui/player_bar.dart
Normal file
160
lib/ui/player_bar.dart
Normal file
|
@ -0,0 +1,160 @@
|
|||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/settings.dart';
|
||||
|
||||
import '../api/player.dart';
|
||||
import 'cached_image.dart';
|
||||
import 'player_screen.dart';
|
||||
|
||||
class PlayerBar extends StatelessWidget {
|
||||
double get progress {
|
||||
if (AudioService.playbackState == null) return 0.0;
|
||||
if (AudioService.currentMediaItem == null) return 0.0;
|
||||
if (AudioService.currentMediaItem.duration.inSeconds == 0) return 0.0; //Division by 0
|
||||
return AudioService.playbackState.currentPosition.inSeconds / AudioService.currentMediaItem.duration.inSeconds;
|
||||
}
|
||||
|
||||
double iconSize = 32;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder(
|
||||
stream: Stream.periodic(Duration(milliseconds: 250)),
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
if (AudioService.currentMediaItem == null) return Container(width: 0, height: 0,);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(builder: (BuildContext context) => PlayerScreen())),
|
||||
leading: CachedImage(
|
||||
width: 50,
|
||||
height: 50,
|
||||
url: AudioService.currentMediaItem.artUri,
|
||||
),
|
||||
title: Text(
|
||||
AudioService.currentMediaItem.displayTitle,
|
||||
overflow: TextOverflow.clip,
|
||||
maxLines: 1,
|
||||
),
|
||||
subtitle: Text(
|
||||
AudioService.currentMediaItem.displaySubtitle,
|
||||
overflow: TextOverflow.clip,
|
||||
maxLines: 1,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
PrevNextButton(iconSize, prev: true, hidePrev: true,),
|
||||
PlayPauseButton(iconSize),
|
||||
PrevNextButton(iconSize)
|
||||
],
|
||||
)
|
||||
),
|
||||
Container(
|
||||
height: 3.0,
|
||||
child: LinearProgressIndicator(
|
||||
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
value: progress,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PrevNextButton extends StatelessWidget {
|
||||
|
||||
final double size;
|
||||
final bool prev;
|
||||
final bool hidePrev;
|
||||
int i;
|
||||
PrevNextButton(this.size, {this.prev = false, this.hidePrev = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!prev) {
|
||||
if (playerHelper.queueIndex == (AudioService.queue??[]).length - 1) {
|
||||
return IconButton(
|
||||
icon: Icon(Icons.skip_next),
|
||||
iconSize: size,
|
||||
onPressed: null,
|
||||
);
|
||||
}
|
||||
return IconButton(
|
||||
icon: Icon(Icons.skip_next),
|
||||
iconSize: size,
|
||||
onPressed: () => AudioService.skipToNext(),
|
||||
);
|
||||
}
|
||||
if (prev) {
|
||||
if (i == 0) {
|
||||
if (hidePrev) {
|
||||
return Container(height: 0, width: 0,);
|
||||
}
|
||||
return IconButton(
|
||||
icon: Icon(Icons.skip_previous),
|
||||
iconSize: size,
|
||||
onPressed: null,
|
||||
);
|
||||
}
|
||||
return IconButton(
|
||||
icon: Icon(Icons.skip_previous),
|
||||
iconSize: size,
|
||||
onPressed: () => AudioService.skipToPrevious(),
|
||||
);
|
||||
}
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class PlayPauseButton extends StatelessWidget {
|
||||
|
||||
final double size;
|
||||
PlayPauseButton(this.size);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
//Playing
|
||||
if (AudioService.playbackState?.playing??false) {
|
||||
return IconButton(
|
||||
iconSize: this.size,
|
||||
icon: Icon(Icons.pause),
|
||||
onPressed: () => AudioService.pause()
|
||||
);
|
||||
}
|
||||
|
||||
//Paused
|
||||
if ((!AudioService.playbackState.playing &&
|
||||
AudioService.playbackState.processingState == AudioProcessingState.ready) ||
|
||||
//None state (stopped)
|
||||
AudioService.playbackState.processingState == AudioProcessingState.none) {
|
||||
return IconButton(
|
||||
iconSize: this.size,
|
||||
icon: Icon(Icons.play_arrow),
|
||||
onPressed: () => AudioService.play()
|
||||
);
|
||||
}
|
||||
|
||||
switch (AudioService.playbackState.processingState) {
|
||||
//Stopped/Error
|
||||
case AudioProcessingState.error:
|
||||
case AudioProcessingState.none:
|
||||
case AudioProcessingState.stopped:
|
||||
return Container(width: this.size, height: this.size);
|
||||
//Loading, connecting, rewinding...
|
||||
default:
|
||||
return Container(
|
||||
width: this.size,
|
||||
height: this.size,
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
581
lib/ui/player_screen.dart
Normal file
581
lib/ui/player_screen.dart
Normal file
|
@ -0,0 +1,581 @@
|
|||
import 'dart:ui';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/ui/menu.dart';
|
||||
import 'package:freezer/ui/tiles.dart';
|
||||
|
||||
import 'cached_image.dart';
|
||||
import '../api/definitions.dart';
|
||||
import 'player_bar.dart';
|
||||
|
||||
|
||||
|
||||
class PlayerScreen extends StatefulWidget {
|
||||
@override
|
||||
_PlayerScreenState createState() => _PlayerScreenState();
|
||||
}
|
||||
|
||||
class _PlayerScreenState extends State<PlayerScreen> {
|
||||
|
||||
double iconSize = 48;
|
||||
bool _lyrics = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: StreamBuilder(
|
||||
stream: AudioService.playbackStateStream,
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
|
||||
//Disable lyrics when skipping songs, loading
|
||||
PlaybackState s = snapshot.data;
|
||||
if (s != null && s.processingState != AudioProcessingState.ready && s.processingState != AudioProcessingState.buffering) _lyrics = false;
|
||||
|
||||
return OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
//Landscape
|
||||
if (orientation == Orientation.landscape) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 32, 16, 8),
|
||||
child: Container(
|
||||
width: 320,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
CachedImage(
|
||||
url: AudioService.currentMediaItem.artUri,
|
||||
),
|
||||
if (_lyrics) LyricsWidget(
|
||||
artUri: AudioService.currentMediaItem.artUri,
|
||||
trackId: AudioService.currentMediaItem.id,
|
||||
lyrics: Track.fromMediaItem(AudioService.currentMediaItem).lyrics,
|
||||
height: 320.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width / 2 - 32,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 42, 8, 0),
|
||||
child: Container(
|
||||
width: 300,
|
||||
child: PlayerScreenTopRow(),
|
||||
)
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
AudioService.currentMediaItem.displayTitle,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.clip,
|
||||
style: TextStyle(
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
Container(height: 4,),
|
||||
Text(
|
||||
AudioService.currentMediaItem.displaySubtitle,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.clip,
|
||||
style: TextStyle(
|
||||
fontSize: 18.0,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Container(
|
||||
width: 320,
|
||||
child: SeekBar(),
|
||||
),
|
||||
Container(
|
||||
width: 320,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: <Widget>[
|
||||
PrevNextButton(iconSize, prev: true,),
|
||||
PlayPauseButton(iconSize),
|
||||
PrevNextButton(iconSize)
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(8, 0, 8, 16),
|
||||
child: Container(
|
||||
width: 300,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.subtitles),
|
||||
onPressed: () {
|
||||
setState(() => _lyrics = !_lyrics);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
AudioService.currentMediaItem.extras['qualityString']
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
Track t = Track.fromMediaItem(AudioService.currentMediaItem);
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
//Portrait
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(28, 28, 28, 0),
|
||||
child: PlayerScreenTopRow()
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Container(
|
||||
height: 360,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
CachedImage(
|
||||
url: AudioService.currentMediaItem.artUri,
|
||||
),
|
||||
if (_lyrics) LyricsWidget(
|
||||
artUri: AudioService.currentMediaItem.artUri,
|
||||
trackId: AudioService.currentMediaItem.id,
|
||||
lyrics: Track.fromMediaItem(AudioService.currentMediaItem).lyrics,
|
||||
height: 360.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
AudioService.currentMediaItem.displayTitle,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.clip,
|
||||
style: TextStyle(
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
Container(height: 4,),
|
||||
Text(
|
||||
AudioService.currentMediaItem.displaySubtitle,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.clip,
|
||||
style: TextStyle(
|
||||
fontSize: 18.0,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SeekBar(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: <Widget>[
|
||||
PrevNextButton(iconSize, prev: true,),
|
||||
PlayPauseButton(iconSize),
|
||||
PrevNextButton(iconSize)
|
||||
],
|
||||
),
|
||||
//Container(height: 8.0,),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.subtitles),
|
||||
onPressed: () {
|
||||
setState(() => _lyrics = !_lyrics);
|
||||
},
|
||||
),
|
||||
Text(
|
||||
AudioService.currentMediaItem.extras['qualityString']
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
Track t = Track.fromMediaItem(AudioService.currentMediaItem);
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LyricsWidget extends StatefulWidget {
|
||||
|
||||
final Lyrics lyrics;
|
||||
final String trackId;
|
||||
final String artUri;
|
||||
final double height;
|
||||
LyricsWidget({this.artUri, this.lyrics, this.trackId, this.height, Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_LyricsWidgetState createState() => _LyricsWidgetState();
|
||||
}
|
||||
|
||||
class _LyricsWidgetState extends State<LyricsWidget> {
|
||||
|
||||
bool _loading = true;
|
||||
Lyrics _l;
|
||||
Color _textColor = Colors.black;
|
||||
ScrollController _scrollController = ScrollController();
|
||||
Timer _timer;
|
||||
int _currentIndex;
|
||||
double _boxHeight;
|
||||
|
||||
Future _load() async {
|
||||
//Get text color by album art (black or white)
|
||||
if (widget.artUri != null) {
|
||||
bool bw = await imagesDatabase.isDark(widget.artUri);
|
||||
if (bw != null) setState(() => _textColor = bw?Colors.white:Colors.black);
|
||||
}
|
||||
|
||||
if (widget.lyrics.lyrics == null || widget.lyrics.lyrics.length == 0) {
|
||||
//Load from api
|
||||
try {
|
||||
_l = await deezerAPI.lyrics(widget.trackId);
|
||||
setState(() => _loading = false);
|
||||
} catch (e) {
|
||||
//Error Lyrics
|
||||
setState(() => _l = Lyrics().error);
|
||||
}
|
||||
} else {
|
||||
//Use provided lyrics
|
||||
_l = widget.lyrics;
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
this._boxHeight = widget.height??400.0;
|
||||
_load();
|
||||
Timer.periodic(Duration(milliseconds: 500), (timer) {
|
||||
_timer = timer;
|
||||
if (_loading) return;
|
||||
//Update index of current lyric
|
||||
setState(() {
|
||||
_currentIndex = _l.lyrics.lastIndexWhere((l) => l.offset <= AudioService.playbackState.currentPosition);
|
||||
});
|
||||
//Scroll to current lyric
|
||||
if (_currentIndex <= 0) return;
|
||||
_scrollController.animateTo(
|
||||
(_boxHeight * _currentIndex),
|
||||
duration: Duration(milliseconds: 250),
|
||||
curve: Curves.ease
|
||||
);
|
||||
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_timer != null) _timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: _boxHeight,
|
||||
width: _boxHeight,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 7.0,
|
||||
sigmaY: 7.0
|
||||
),
|
||||
child: Container(
|
||||
child: _loading?
|
||||
Center(child: CircularProgressIndicator(),) :
|
||||
SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: Column(
|
||||
children: List.generate(_l.lyrics.length, (i) {
|
||||
return Container(
|
||||
height: _boxHeight,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_l.lyrics[i].text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: _textColor,
|
||||
fontSize: 40.0,
|
||||
fontWeight: (_currentIndex == i)?FontWeight.bold:FontWeight.normal
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}),
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//Top row containing QueueSource, queue...
|
||||
class PlayerScreenTopRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'Playing from: ' + playerHelper.queueSource.text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(fontSize: 16.0),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
RepeatButton(),
|
||||
Container(width: 16.0,),
|
||||
InkWell(
|
||||
child: Icon(Icons.menu),
|
||||
onTap: (){
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => QueueScreen()
|
||||
));
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class RepeatButton extends StatefulWidget {
|
||||
@override
|
||||
_RepeatButtonState createState() => _RepeatButtonState();
|
||||
}
|
||||
|
||||
class _RepeatButtonState extends State<RepeatButton> {
|
||||
|
||||
Icon get icon {
|
||||
switch (playerHelper.repeatType) {
|
||||
case RepeatType.NONE:
|
||||
return Icon(Icons.repeat);
|
||||
case RepeatType.LIST:
|
||||
return Icon(
|
||||
Icons.repeat,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
case RepeatType.TRACK:
|
||||
return Icon(
|
||||
Icons.repeat_one,
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
await playerHelper.changeRepeat();
|
||||
setState(() {});
|
||||
},
|
||||
child: icon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class SeekBar extends StatefulWidget {
|
||||
@override
|
||||
_SeekBarState createState() => _SeekBarState();
|
||||
}
|
||||
|
||||
class _SeekBarState extends State<SeekBar> {
|
||||
|
||||
bool _seeking = false;
|
||||
double _pos;
|
||||
|
||||
double get position {
|
||||
if (_seeking) return _pos;
|
||||
if (AudioService.playbackState == null) return 0.0;
|
||||
double p = AudioService.playbackState.currentPosition.inMilliseconds.toDouble()??0.0;
|
||||
if (p > duration) return duration;
|
||||
return p;
|
||||
}
|
||||
|
||||
//Duration to mm:ss
|
||||
String _timeString(double pos) {
|
||||
Duration d = Duration(milliseconds: pos.toInt());
|
||||
return "${d.inMinutes}:${d.inSeconds.remainder(60).toString().padLeft(2, '0')}";
|
||||
}
|
||||
|
||||
double get duration {
|
||||
if (AudioService.currentMediaItem == null) return 1.0;
|
||||
return AudioService.currentMediaItem.duration.inMilliseconds.toDouble();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder(
|
||||
stream: Stream.periodic(Duration(milliseconds: 250)),
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 0.0, horizontal: 24.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
_timeString(position),
|
||||
style: TextStyle(
|
||||
fontSize: 14.0
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_timeString(duration),
|
||||
style: TextStyle(
|
||||
fontSize: 14.0
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 32.0,
|
||||
child: Slider(
|
||||
value: position,
|
||||
max: duration,
|
||||
onChangeStart: (double d) {
|
||||
setState(() {
|
||||
_seeking = true;
|
||||
_pos = d;
|
||||
});
|
||||
},
|
||||
onChanged: (double d) {
|
||||
setState(() {
|
||||
_pos = d;
|
||||
});
|
||||
},
|
||||
onChangeEnd: (double d) async {
|
||||
await AudioService.seekTo(Duration(milliseconds: d.round()));
|
||||
setState(() {
|
||||
_pos = d;
|
||||
_seeking = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QueueScreen extends StatefulWidget {
|
||||
@override
|
||||
_QueueScreenState createState() => _QueueScreenState();
|
||||
}
|
||||
|
||||
class _QueueScreenState extends State<QueueScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Queue'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.shuffle),
|
||||
onPressed: () async {
|
||||
await AudioService.customAction('shuffleQueue');
|
||||
setState(() => {});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: AudioService.queue.length,
|
||||
itemBuilder: (context, i) {
|
||||
Track t = Track.fromMediaItem(AudioService.queue[i]);
|
||||
return TrackTile(
|
||||
t,
|
||||
onTap: () async {
|
||||
await AudioService.playFromMediaId(t.id);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
387
lib/ui/search.dart
Normal file
387
lib/ui/search.dart
Normal file
|
@ -0,0 +1,387 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:freezer/api/download.dart';
|
||||
import 'package:freezer/api/player.dart';
|
||||
import 'package:freezer/ui/details_screens.dart';
|
||||
import 'package:freezer/ui/menu.dart';
|
||||
|
||||
import 'tiles.dart';
|
||||
import '../api/deezer.dart';
|
||||
import '../api/definitions.dart';
|
||||
import '../settings.dart';
|
||||
import 'error.dart';
|
||||
|
||||
class SearchScreen extends StatefulWidget {
|
||||
@override
|
||||
_SearchScreenState createState() => _SearchScreenState();
|
||||
}
|
||||
|
||||
class _SearchScreenState extends State<SearchScreen> {
|
||||
|
||||
String _query;
|
||||
bool _offline = settings.offlineMode;
|
||||
|
||||
void _submit(BuildContext context, {String query}) {
|
||||
if (query != null) _query = query;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => SearchResultsScreen(_query, offline: _offline,))
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Search'),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
Container(height: 16.0),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: TextField(
|
||||
onChanged: (String s) => _query = s,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Search'
|
||||
),
|
||||
onSubmitted: (String s) => _submit(context, query: s),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.search),
|
||||
onPressed: () => _submit(context),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Offline search'),
|
||||
leading: Switch(
|
||||
value: _offline,
|
||||
onChanged: (v) {
|
||||
if (settings.offlineMode) {
|
||||
setState(() => _offline = true);
|
||||
} else {
|
||||
setState(() => _offline = v);
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
class SearchResultsScreen extends StatelessWidget {
|
||||
|
||||
final String query;
|
||||
final bool offline;
|
||||
|
||||
SearchResultsScreen(this.query, {this.offline});
|
||||
|
||||
Future _search() async {
|
||||
if (offline??false) {
|
||||
return await downloadManager.search(query);
|
||||
}
|
||||
return await deezerAPI.search(query);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Search Results'),
|
||||
),
|
||||
body: FutureBuilder(
|
||||
future: _search(),
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
|
||||
if (!snapshot.hasData) return Center(child: CircularProgressIndicator(),);
|
||||
if (snapshot.hasError) return ErrorScreen();
|
||||
|
||||
SearchResults results = snapshot.data;
|
||||
|
||||
if (results.empty)
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
Icons.warning,
|
||||
size: 64,
|
||||
),
|
||||
Text('No results!')
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
//Tracks
|
||||
List<Widget> tracks = [];
|
||||
if (results.tracks != null && results.tracks.length != 0) {
|
||||
tracks = [
|
||||
Text(
|
||||
'Tracks',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 26.0
|
||||
),
|
||||
),
|
||||
...List.generate(3, (i) {
|
||||
if (results.tracks.length <= i) return Container(width: 0, height: 0,);
|
||||
Track t = results.tracks[i];
|
||||
return TrackTile(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTrackList(results.tracks, t.id, QueueSource(
|
||||
text: 'Search',
|
||||
id: query,
|
||||
source: 'search'
|
||||
));
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t);
|
||||
},
|
||||
);
|
||||
}),
|
||||
ListTile(
|
||||
title: Text('Show all tracks'),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => TrackListScreen(results.tracks, QueueSource(
|
||||
id: query,
|
||||
source: 'search',
|
||||
text: 'Search'
|
||||
)))
|
||||
);
|
||||
},
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
//Albums
|
||||
List<Widget> albums = [];
|
||||
if (results.albums != null && results.albums.length != 0) {
|
||||
albums = [
|
||||
Text(
|
||||
'Albums',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 26.0
|
||||
),
|
||||
),
|
||||
...List.generate(3, (i) {
|
||||
if (results.albums.length <= i) return Container(height: 0, width: 0,);
|
||||
Album a = results.albums[i];
|
||||
return AlbumTile(
|
||||
a,
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(a);
|
||||
},
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => AlbumDetails(a))
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
ListTile(
|
||||
title: Text('Show all albums'),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => AlbumListScreen(results.albums))
|
||||
);
|
||||
},
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
//Artists
|
||||
List<Widget> artists = [];
|
||||
if (results.artists != null && results.artists.length != 0) {
|
||||
artists = [
|
||||
Text(
|
||||
'Artists',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 26.0
|
||||
),
|
||||
),
|
||||
Container(height: 4),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: List.generate(results.artists.length, (int i) {
|
||||
Artist a = results.artists[i];
|
||||
return ArtistTile(
|
||||
a,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => ArtistDetails(a))
|
||||
);
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultArtistMenu(a);
|
||||
},
|
||||
);
|
||||
}),
|
||||
)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
//Playlists
|
||||
List<Widget> playlists = [];
|
||||
if (results.playlists != null && results.playlists.length != 0) {
|
||||
playlists = [
|
||||
Text(
|
||||
'Playlists',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 26.0
|
||||
),
|
||||
),
|
||||
...List.generate(3, (i) {
|
||||
if (results.playlists.length <= i) return Container(height: 0, width: 0,);
|
||||
Playlist p = results.playlists[i];
|
||||
return PlaylistTile(
|
||||
p,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => PlaylistDetails(p))
|
||||
);
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultPlaylistMenu(p);
|
||||
},
|
||||
);
|
||||
}),
|
||||
ListTile(
|
||||
title: Text('Show all playlists'),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => SearchResultPlaylists(results.playlists))
|
||||
);
|
||||
},
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
return ListView(
|
||||
children: <Widget>[
|
||||
Container(height: 8.0,),
|
||||
...tracks,
|
||||
Container(height: 8.0,),
|
||||
...albums,
|
||||
Container(height: 8.0,),
|
||||
...artists,
|
||||
Container(height: 8.0,),
|
||||
...playlists
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//List all tracks
|
||||
class TrackListScreen extends StatelessWidget {
|
||||
|
||||
final QueueSource queueSource;
|
||||
final List<Track> tracks;
|
||||
|
||||
TrackListScreen(this.tracks, this.queueSource);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Tracks'),),
|
||||
body: ListView.builder(
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (BuildContext context, int i) {
|
||||
Track t = tracks[i];
|
||||
return TrackTile(
|
||||
t,
|
||||
onTap: () {
|
||||
playerHelper.playFromTrackList(tracks, t.id, queueSource);
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultTrackMenu(t);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//List all albums
|
||||
class AlbumListScreen extends StatelessWidget {
|
||||
|
||||
final List<Album> albums;
|
||||
AlbumListScreen(this.albums);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Albums'),),
|
||||
body: ListView.builder(
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, i) {
|
||||
Album a = albums[i];
|
||||
return AlbumTile(
|
||||
a,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => AlbumDetails(a))
|
||||
);
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultAlbumMenu(a);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchResultPlaylists extends StatelessWidget {
|
||||
|
||||
final List<Playlist> playlists;
|
||||
SearchResultPlaylists(this.playlists);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Playlists'),),
|
||||
body: ListView.builder(
|
||||
itemCount: playlists.length,
|
||||
itemBuilder: (context, i) {
|
||||
Playlist p = playlists[i];
|
||||
return PlaylistTile(
|
||||
p,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => PlaylistDetails(p))
|
||||
);
|
||||
},
|
||||
onHold: () {
|
||||
MenuSheet m = MenuSheet(context);
|
||||
m.defaultPlaylistMenu(p);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
655
lib/ui/settings_screen.dart
Normal file
655
lib/ui/settings_screen.dart
Normal file
|
@ -0,0 +1,655 @@
|
|||
import 'package:country_pickers/country.dart';
|
||||
import 'package:country_pickers/country_picker_dialog.dart';
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_material_color_picker/flutter_material_color_picker.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezer/api/deezer.dart';
|
||||
import 'package:freezer/ui/error.dart';
|
||||
import 'package:freezer/ui/player_bar.dart';
|
||||
import 'package:language_pickers/language_pickers.dart';
|
||||
import 'package:language_pickers/languages.dart';
|
||||
import 'package:package_info/package_info.dart';
|
||||
import 'package:path_provider_ex/path_provider_ex.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../settings.dart';
|
||||
import '../main.dart';
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
@override
|
||||
_SettingsScreenState createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
String _about = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
//Load about text
|
||||
PackageInfo.fromPlatform().then((PackageInfo info) {
|
||||
setState(() {
|
||||
_about = '${info.appName} ${info.version}';
|
||||
});
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Settings'),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('General'),
|
||||
leading: Icon(Icons.settings),
|
||||
onTap: () => Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => GeneralSettings()
|
||||
)),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Appearance'),
|
||||
leading: Icon(Icons.color_lens),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => AppearanceSettings())
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Quality'),
|
||||
leading: Icon(Icons.high_quality),
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => QualitySettings())
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Deezer'),
|
||||
leading: Icon(Icons.equalizer),
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => DeezerSettings()
|
||||
)),
|
||||
),
|
||||
Divider(),
|
||||
Text(
|
||||
_about,
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppearanceSettings extends StatefulWidget {
|
||||
@override
|
||||
_AppearanceSettingsState createState() => _AppearanceSettingsState();
|
||||
}
|
||||
|
||||
class _AppearanceSettingsState extends State<AppearanceSettings> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Appearance'),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('Theme'),
|
||||
subtitle: Text('Currently: ${settings.theme.toString().split('.').last}'),
|
||||
leading: Icon(Icons.color_lens),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SimpleDialog(
|
||||
title: Text('Select theme'),
|
||||
children: <Widget>[
|
||||
SimpleDialogOption(
|
||||
child: Text('Light (default)'),
|
||||
onPressed: () {
|
||||
setState(() => settings.theme = Themes.Light);
|
||||
settings.save();
|
||||
updateTheme();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
SimpleDialogOption(
|
||||
child: Text('Dark'),
|
||||
onPressed: () {
|
||||
setState(() => settings.theme = Themes.Dark);
|
||||
settings.save();
|
||||
updateTheme();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
SimpleDialogOption(
|
||||
child: Text('Black (AMOLED)'),
|
||||
onPressed: () {
|
||||
setState(() => settings.theme = Themes.Black);
|
||||
settings.save();
|
||||
updateTheme();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Primary color'),
|
||||
leading: Icon(Icons.format_paint),
|
||||
subtitle: Text(
|
||||
'Selected color',
|
||||
style: TextStyle(
|
||||
color: settings.primaryColor
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Primary color'),
|
||||
content: Container(
|
||||
height: 200,
|
||||
child: MaterialColorPicker(
|
||||
allowShades: false,
|
||||
selectedColor: settings.primaryColor,
|
||||
onMainColorChange: (ColorSwatch color) {
|
||||
setState(() {
|
||||
settings.primaryColor = color;
|
||||
});
|
||||
settings.save();
|
||||
updateTheme();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Use album art primary color'),
|
||||
subtitle: Text('Warning: might be buggy'),
|
||||
leading: Switch(
|
||||
value: settings.useArtColor,
|
||||
onChanged: (v) => setState(() => settings.updateUseArtColor(v)),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class QualitySettings extends StatefulWidget {
|
||||
@override
|
||||
_QualitySettingsState createState() => _QualitySettingsState();
|
||||
}
|
||||
|
||||
class _QualitySettingsState extends State<QualitySettings> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Quality'),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('Mobile streaming'),
|
||||
leading: Icon(Icons.network_cell),
|
||||
),
|
||||
QualityPicker('mobile'),
|
||||
Divider(),
|
||||
ListTile(
|
||||
title: Text('Wifi streaming'),
|
||||
leading: Icon(Icons.network_wifi),
|
||||
),
|
||||
QualityPicker('wifi'),
|
||||
Divider(),
|
||||
ListTile(
|
||||
title: Text('Offline'),
|
||||
leading: Icon(Icons.offline_pin),
|
||||
),
|
||||
QualityPicker('offline'),
|
||||
Divider(),
|
||||
ListTile(
|
||||
title: Text('External downloads'),
|
||||
leading: Icon(Icons.file_download),
|
||||
),
|
||||
QualityPicker('download'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class QualityPicker extends StatefulWidget {
|
||||
|
||||
final String field;
|
||||
QualityPicker(this.field, {Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_QualityPickerState createState() => _QualityPickerState();
|
||||
}
|
||||
|
||||
class _QualityPickerState extends State<QualityPicker> {
|
||||
|
||||
AudioQuality _quality;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_getQuality();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
//Get current quality
|
||||
void _getQuality() {
|
||||
switch (widget.field) {
|
||||
case 'mobile':
|
||||
_quality = settings.mobileQuality; break;
|
||||
case 'wifi':
|
||||
_quality = settings.wifiQuality; break;
|
||||
case 'download':
|
||||
_quality = settings.downloadQuality; break;
|
||||
case 'offline':
|
||||
_quality = settings.offlineQuality; break;
|
||||
}
|
||||
}
|
||||
|
||||
//Update quality in settings
|
||||
void _updateQuality(AudioQuality q) {
|
||||
setState(() {
|
||||
_quality = q;
|
||||
});
|
||||
switch (widget.field) {
|
||||
case 'mobile':
|
||||
settings.mobileQuality = _quality; break;
|
||||
case 'wifi':
|
||||
settings.wifiQuality = _quality; break;
|
||||
case 'download':
|
||||
settings.downloadQuality = _quality; break;
|
||||
case 'offline':
|
||||
settings.offlineQuality = _quality; break;
|
||||
}
|
||||
settings.updateAudioServiceQuality();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
//Save
|
||||
settings.updateAudioServiceQuality();
|
||||
settings.save();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('MP3 128kbps'),
|
||||
leading: Radio(
|
||||
groupValue: _quality,
|
||||
value: AudioQuality.MP3_128,
|
||||
onChanged: (q) => _updateQuality(q),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('MP3 320kbps'),
|
||||
leading: Radio(
|
||||
groupValue: _quality,
|
||||
value: AudioQuality.MP3_320,
|
||||
onChanged: (q) => _updateQuality(q),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('FLAC'),
|
||||
leading: Radio(
|
||||
groupValue: _quality,
|
||||
value: AudioQuality.FLAC,
|
||||
onChanged: (q) => _updateQuality(q),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DeezerSettings extends StatefulWidget {
|
||||
@override
|
||||
_DeezerSettingsState createState() => _DeezerSettingsState();
|
||||
}
|
||||
|
||||
class _DeezerSettingsState extends State<DeezerSettings> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('Deezer'),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('Content language'),
|
||||
subtitle: Text('Not app language, used in headers. Now: ${settings.deezerLanguage}'),
|
||||
leading: Icon(Icons.language),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => LanguagePickerDialog(
|
||||
titlePadding: EdgeInsets.all(8.0),
|
||||
isSearchable: true,
|
||||
title: Text('Select language'),
|
||||
onValuePicked: (Language language) {
|
||||
setState(() => settings.deezerLanguage = language.isoCode);
|
||||
settings.save();
|
||||
},
|
||||
)
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Content country'),
|
||||
subtitle: Text('Country used in headers. Now: ${settings.deezerCountry}'),
|
||||
leading: Icon(Icons.vpn_lock),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => CountryPickerDialog(
|
||||
titlePadding: EdgeInsets.all(8.0),
|
||||
isSearchable: true,
|
||||
onValuePicked: (Country country) {
|
||||
setState(() => settings.deezerCountry = country.isoCode);
|
||||
settings.save();
|
||||
},
|
||||
)
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Log tracks'),
|
||||
subtitle: Text('Send track listen logs to Deezer, enable it for features like Flow to work properly'),
|
||||
leading: Checkbox(
|
||||
value: settings.logListen,
|
||||
onChanged: (bool v) {
|
||||
setState(() => settings.logListen = v);
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GeneralSettings extends StatefulWidget {
|
||||
@override
|
||||
_GeneralSettingsState createState() => _GeneralSettingsState();
|
||||
}
|
||||
|
||||
class _GeneralSettingsState extends State<GeneralSettings> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('General'),),
|
||||
body: ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('Offline mode'),
|
||||
subtitle: Text('Will be overwritten on start.'),
|
||||
leading: Switch(
|
||||
value: settings.offlineMode,
|
||||
onChanged: (bool v) {
|
||||
if (v) {
|
||||
setState(() => settings.offlineMode = true);
|
||||
return;
|
||||
}
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
deezerAPI.authorize().then((v) {
|
||||
if (v) {
|
||||
setState(() => settings.offlineMode = false);
|
||||
} else {
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Error logging in, check your internet connections.',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastLength: Toast.LENGTH_SHORT
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
});
|
||||
return AlertDialog(
|
||||
title: Text('Logging in...'),
|
||||
content: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
CircularProgressIndicator()
|
||||
],
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Download path'),
|
||||
leading: Icon(Icons.folder),
|
||||
subtitle: Text(settings.downloadPath),
|
||||
onTap: () async {
|
||||
//Check permissions
|
||||
if (!(await Permission.storage.request().isGranted)) return;
|
||||
//Navigate
|
||||
Navigator.of(context).push(MaterialPageRoute(
|
||||
builder: (context) => DirectoryPicker(settings.downloadPath, onSelect: (String p) {
|
||||
setState(() => settings.downloadPath = p);
|
||||
},)
|
||||
));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Downloads naming'),
|
||||
leading: Icon(Icons.text_format),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SimpleDialog(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text('Default naming'),
|
||||
subtitle: Text('01. Title'),
|
||||
onTap: () {
|
||||
settings.downloadNaming = DownloadNaming.DEFAULT;
|
||||
Navigator.of(context).pop();
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Standalone naming'),
|
||||
subtitle: Text('Artist - Title'),
|
||||
onTap: () {
|
||||
settings.downloadNaming = DownloadNaming.STANDALONE;
|
||||
Navigator.of(context).pop();
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Create download folder structure'),
|
||||
subtitle: Text('Artist/Album/Track'),
|
||||
leading: Switch(
|
||||
value: settings.downloadFolderStructure,
|
||||
onChanged: (v) {
|
||||
setState(() => settings.downloadFolderStructure = v);
|
||||
settings.save();
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DirectoryPicker extends StatefulWidget {
|
||||
|
||||
final String initialPath;
|
||||
final Function onSelect;
|
||||
DirectoryPicker(this.initialPath, {this.onSelect, Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_DirectoryPickerState createState() => _DirectoryPickerState();
|
||||
}
|
||||
|
||||
class _DirectoryPickerState extends State<DirectoryPicker> {
|
||||
|
||||
String _path;
|
||||
String _previous;
|
||||
String _root;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_path = widget.initialPath;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Pick-a-Path'),
|
||||
actions: <Widget>[
|
||||
IconButton(
|
||||
icon: Icon(Icons.sd_card),
|
||||
onPressed: () {
|
||||
String path = '';
|
||||
//Chose storage
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text('Select storage'),
|
||||
content: FutureBuilder(
|
||||
future: PathProviderEx.getStorageInfo(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return ErrorScreen();
|
||||
if (!snapshot.hasData) return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
CircularProgressIndicator()
|
||||
],
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
...List.generate(snapshot.data.length, (i) {
|
||||
StorageInfo si = snapshot.data[i];
|
||||
return ListTile(
|
||||
title: Text(si.rootDir),
|
||||
leading: Icon(Icons.sd_card),
|
||||
trailing: Text(filesize(si.availableBytes)),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_path = si.appFilesDir;
|
||||
//Android 5+ blocks sd card, so this prevents going outside
|
||||
//app data dir, until permission request fix.
|
||||
_root = si.rootDir;
|
||||
if (i != 0) _root = si.appFilesDir;
|
||||
});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
);
|
||||
})
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: Icon(Icons.done),
|
||||
onPressed: () {
|
||||
//When folder confirmed
|
||||
if (widget.onSelect != null) widget.onSelect(_path);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
body: FutureBuilder(
|
||||
future: Directory(_path).list().toList(),
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) {
|
||||
|
||||
//On error go to last good path
|
||||
if (snapshot.hasError) Future.delayed(Duration(milliseconds: 50), () => setState(() => _path = _previous));
|
||||
if (!snapshot.hasData) return Center(child: CircularProgressIndicator(),);
|
||||
|
||||
List<FileSystemEntity> data = snapshot.data;
|
||||
return ListView(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
title: Text(_path),
|
||||
),
|
||||
ListTile(
|
||||
title: Text('Go up'),
|
||||
leading: Icon(Icons.arrow_upward),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (_root == _path) {
|
||||
Fluttertoast.showToast(
|
||||
msg: 'Permission denied',
|
||||
gravity: ToastGravity.BOTTOM
|
||||
);
|
||||
return;
|
||||
}
|
||||
_previous = _path;
|
||||
_path = Directory(_path).parent.path;
|
||||
});
|
||||
},
|
||||
),
|
||||
...List.generate(data.length, (i) {
|
||||
FileSystemEntity f = data[i];
|
||||
if (f is Directory) {
|
||||
return ListTile(
|
||||
title: Text(f.path.split('/').last),
|
||||
leading: Icon(Icons.folder),
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_previous = _path;
|
||||
_path = f.path;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
return Container(height: 0, width: 0,);
|
||||
})
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
363
lib/ui/tiles.dart
Normal file
363
lib/ui/tiles.dart
Normal file
|
@ -0,0 +1,363 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/definitions.dart';
|
||||
import 'cached_image.dart';
|
||||
|
||||
class TrackTile extends StatefulWidget {
|
||||
|
||||
final Track track;
|
||||
final Function onTap;
|
||||
final Function onHold;
|
||||
final Widget trailing;
|
||||
|
||||
TrackTile(this.track, {this.onTap, this.onHold, this.trailing, Key key}): super(key: key);
|
||||
|
||||
@override
|
||||
_TrackTileState createState() => _TrackTileState();
|
||||
}
|
||||
|
||||
class _TrackTileState extends State<TrackTile> {
|
||||
|
||||
StreamSubscription _subscription;
|
||||
|
||||
bool get nowPlaying {
|
||||
if (AudioService.currentMediaItem == null) return false;
|
||||
return AudioService.currentMediaItem.id == widget.track.id;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
//Listen to media item changes, update text color if currently playing
|
||||
_subscription = AudioService.currentMediaItemStream.listen((event) {
|
||||
setState(() {});
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_subscription != null) _subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
widget.track.title,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: nowPlaying?Theme.of(context).primaryColor:null
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
widget.track.artistString,
|
||||
maxLines: 1,
|
||||
),
|
||||
leading: CachedImage(
|
||||
url: widget.track.albumArt.thumb,
|
||||
),
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.onHold,
|
||||
trailing: widget.trailing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumTile extends StatelessWidget {
|
||||
|
||||
final Album album;
|
||||
final Function onTap;
|
||||
final Function onHold;
|
||||
final Widget trailing;
|
||||
|
||||
AlbumTile(this.album, {this.onTap, this.onHold, this.trailing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
album.title,
|
||||
maxLines: 1,
|
||||
),
|
||||
subtitle: Text(
|
||||
album.artistString,
|
||||
maxLines: 1,
|
||||
),
|
||||
leading: CachedImage(
|
||||
url: album.art.thumb,
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ArtistTile extends StatelessWidget {
|
||||
|
||||
final Artist artist;
|
||||
final Function onTap;
|
||||
final Function onHold;
|
||||
|
||||
ArtistTile(this.artist, {this.onTap, this.onHold});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 150,
|
||||
child: Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(height: 4,),
|
||||
CachedImage(
|
||||
url: artist.picture.thumb,
|
||||
circular: true,
|
||||
width: 64,
|
||||
),
|
||||
Container(height: 4,),
|
||||
Text(
|
||||
artist.name,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16.0
|
||||
),
|
||||
),
|
||||
Container(height: 4,),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlaylistTile extends StatelessWidget {
|
||||
|
||||
final Playlist playlist;
|
||||
final Function onTap;
|
||||
final Function onHold;
|
||||
final Widget trailing;
|
||||
|
||||
PlaylistTile(this.playlist, {this.onHold, this.onTap, this.trailing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
playlist.title,
|
||||
maxLines: 1,
|
||||
),
|
||||
subtitle: Text(
|
||||
playlist.user.name,
|
||||
maxLines: 1,
|
||||
),
|
||||
leading: CachedImage(
|
||||
url: playlist.image.thumb,
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ArtistHorizontalTile extends StatelessWidget {
|
||||
|
||||
final Artist artist;
|
||||
final Function onTap;
|
||||
final Function onHold;
|
||||
final Widget trailing;
|
||||
|
||||
ArtistHorizontalTile(this.artist, {this.onHold, this.onTap, this.trailing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
artist.name,
|
||||
maxLines: 1,
|
||||
),
|
||||
leading: CachedImage(
|
||||
url: artist.picture.thumb,
|
||||
circular: true,
|
||||
),
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
trailing: trailing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlaylistCardTile extends StatelessWidget {
|
||||
|
||||
final Playlist playlist;
|
||||
final Function onTap;
|
||||
final Function onHold;
|
||||
PlaylistCardTile(this.playlist, {this.onTap, this.onHold});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: CachedImage(
|
||||
url: playlist.image.thumb,
|
||||
width: 128,
|
||||
height: 128,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 144,
|
||||
child: Text(
|
||||
playlist.title,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 16.0),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SmartTrackListTile extends StatelessWidget {
|
||||
|
||||
final SmartTrackList smartTrackList;
|
||||
final Function onTap;
|
||||
final Function onHold;
|
||||
SmartTrackListTile(this.smartTrackList, {this.onHold, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CachedImage(
|
||||
width: 128,
|
||||
height: 128,
|
||||
url: smartTrackList.cover.thumb,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 144.0,
|
||||
child: Text(
|
||||
smartTrackList.title,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16.0
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AlbumCard extends StatelessWidget {
|
||||
|
||||
final Album album;
|
||||
final Function onTap;
|
||||
final Function onHold;
|
||||
|
||||
AlbumCard(this.album, {this.onTap, this.onHold});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onHold,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CachedImage(
|
||||
width: 128.0,
|
||||
height: 128.0,
|
||||
url: album.art.thumb,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 144.0,
|
||||
child: Text(
|
||||
album.title,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16.0
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelTile extends StatelessWidget {
|
||||
|
||||
final DeezerChannel channel;
|
||||
final Function onTap;
|
||||
ChannelTile(this.channel, {this.onTap});
|
||||
|
||||
Color _textColor() {
|
||||
double luminance = channel.backgroundColor.computeLuminance();
|
||||
return (luminance>0.5)?Colors.black:Colors.white;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
color: channel.backgroundColor,
|
||||
child: InkWell(
|
||||
onTap: this.onTap,
|
||||
child: Container(
|
||||
width: 150,
|
||||
height: 75,
|
||||
child: Center(
|
||||
child: Text(
|
||||
channel.title,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 18.0,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _textColor()
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue