Initial commit

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

356
lib/api/deezer.dart Normal file
View file

@ -0,0 +1,356 @@
import 'package:dio/dio.dart';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'dart:io';
import 'dart:convert';
import '../settings.dart';
import 'definitions.dart';
DeezerAPI deezerAPI = DeezerAPI();
class DeezerAPI {
String arl;
DeezerAPI({this.arl});
String token;
String userId;
String favoritesPlaylistId;
String privateUrl = 'http://www.deezer.com/ajax/gw-light.php';
Map<String, String> headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36",
"Content-Language": '${settings.deezerLanguage??"en"}-${settings.deezerCountry??'US'}',
"Cache-Control": "max-age=0",
"Accept": "*/*",
"Accept-Charset": "utf-8,ISO-8859-1;q=0.7,*;q=0.3",
"Accept-Language": "${settings.deezerLanguage??"en"}-${settings.deezerCountry??'US'},${settings.deezerLanguage??"en"};q=0.9,en-US;q=0.8,en;q=0.7",
"Connection": "keep-alive"
};
CookieJar _cookieJar = new CookieJar();
//Call private api
Future<Map<dynamic, dynamic>> callApi(String method, {Map<dynamic, dynamic> params, String gatewayInput}) async {
Dio dio = Dio();
//Add headers
dio.interceptors.add(InterceptorsWrapper(
onRequest: (RequestOptions options) {
options.headers = this.headers;
return options;
}
));
//Add cookies
List<Cookie> cookies = [Cookie('arl', this.arl)];
_cookieJar.saveFromResponse(Uri.parse(this.privateUrl), cookies);
dio.interceptors.add(CookieManager(_cookieJar));
//Make request
Response<dynamic> response = await dio.post(
this.privateUrl,
queryParameters: {
'api_version': '1.0',
'api_token': this.token,
'input': '3',
'method': method,
//Used for homepage
if (gatewayInput != null)
'gateway_input': gatewayInput
},
data: jsonEncode(params??{}),
options: Options(responseType: ResponseType.json, sendTimeout: 7000, receiveTimeout: 7000)
);
return response.data;
}
//Authorize, bool = success
Future<bool> authorize() async {
try {
Map<dynamic, dynamic> data = await callApi('deezer.getUserData');
if (data['results']['USER']['USER_ID'] == 0) {
return false;
} else {
this.token = data['results']['checkForm'];
this.userId = data['results']['USER']['USER_ID'].toString();
this.favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID'];
return true;
}
} catch (e) { return false; }
}
//Search
Future<SearchResults> search(String query) async {
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch', params: {
'nb': 50,
'query': query,
'start': 0
});
return SearchResults.fromPrivateJson(data['results']);
}
Future<Track> track(String id) async {
Map<dynamic, dynamic> data = await callApi('song.getListData', params: {'sng_ids': [id]});
return Track.fromPrivateJson(data['results']['data'][0]);
}
//Get album details, tracks
Future<Album> album(String id) async {
Map<dynamic, dynamic> data = await callApi('deezer.pageAlbum', params: {
'alb_id': id,
'header': true,
'lang': 'us'
});
return Album.fromPrivateJson(data['results']['DATA'], songsJson: data['results']['SONGS']);
}
//Get artist details
Future<Artist> artist(String id) async {
Map<dynamic, dynamic> data = await callApi('deezer.pageArtist', params: {
'art_id': id,
'lang': 'us',
});
return Artist.fromPrivateJson(
data['results']['DATA'],
topJson: data['results']['TOP'],
albumsJson: data['results']['ALBUMS']
);
}
//Get playlist tracks at offset
Future<List<Track>> playlistTracksPage(String id, int start, {int nb = 50}) async {
Map data = await callApi('deezer.pagePlaylist', params: {
'playlist_id': id,
'lang': 'us',
'nb': nb,
'tags': true,
'start': start
});
return data['results']['SONGS']['data'].map<Track>((json) => Track.fromPrivateJson(json)).toList();
}
//Get playlist details
Future<Playlist> playlist(String id, {int nb = 100}) async {
Map<dynamic, dynamic> data = await callApi('deezer.pagePlaylist', params: {
'playlist_id': id,
'lang': 'us',
'nb': nb,
'tags': true,
'start': 0
});
return Playlist.fromPrivateJson(data['results']['DATA'], songsJson: data['results']['SONGS']);
}
//Get playlist with all tracks
Future<Playlist> fullPlaylist(String id) async {
Playlist p = await playlist(id, nb: 200);
for (int i=200; i<p.trackCount; i++) {
//Get another page of tracks
List<Track> tracks = await playlistTracksPage(id, i, nb: 200);
p.tracks.addAll(tracks);
i += 200;
continue;
}
return p;
}
//Add track to favorites
Future addFavoriteTrack(String id) async {
await callApi('favorite_song.add', params: {'SNG_ID': id});
}
//Add album to favorites/library
Future addFavoriteAlbum(String id) async {
await callApi('album.addFavorite', params: {'ALB_ID': id});
}
//Add artist to favorites/library
Future addFavoriteArtist(String id) async {
await callApi('artist.addFavorite', params: {'ART_ID': id});
}
//Remove artist from favorites/library
Future removeArtist(String id) async {
await callApi('artist.deleteFavorite', params: {'ART_ID': id});
}
//Add tracks to playlist
Future addToPlaylist(String trackId, String playlistId, {int offset = -1}) async {
await callApi('playlist.addSongs', params: {
'offset': offset,
'playlist_id': playlistId,
'songs': [[trackId, 0]]
});
}
//Remove track from playlist
Future removeFromPlaylist(String trackId, String playlistId) async {
await callApi('playlist.deleteSongs', params: {
'playlist_id': playlistId,
'songs': [[trackId, 0]]
});
}
//Get users playlists
Future<List<Playlist>> getPlaylists() async {
Map data = await callApi('deezer.pageProfile', params: {
'nb': 100,
'tab': 'playlists',
'user_id': this.userId
});
return data['results']['TAB']['playlists']['data'].map<Playlist>((json) => Playlist.fromPrivateJson(json, library: true)).toList();
}
//Get favorite albums
Future<List<Album>> getAlbums() async {
Map data = await callApi('deezer.pageProfile', params: {
'nb': 50,
'tab': 'albums',
'user_id': this.userId
});
List albumList = data['results']['TAB']['albums']['data'];
List<Album> albums = albumList.map<Album>((json) => Album.fromPrivateJson(json, library: true)).toList();
return albums;
}
//Remove album from library
Future removeAlbum(String id) async {
await callApi('album.deleteFavorite', params: {
'ALB_ID': id
});
}
//Remove track from favorites
Future removeFavorite(String id) async {
await callApi('favorite_song.remove', params: {
'SNG_ID': id
});
}
//Get favorite artists
Future<List<Artist>> getArtists() async {
Map data = await callApi('deezer.pageProfile', params: {
'nb': 40,
'tab': 'artists',
'user_id': this.userId
});
return data['results']['TAB']['artists']['data'].map<Artist>((json) => Artist.fromPrivateJson(json, library: true)).toList();
}
//Get lyrics by track id
Future<Lyrics> lyrics(String trackId) async {
Map data = await callApi('song.getLyrics', params: {
'sng_id': trackId
});
if (data['error'] != null && data['error'].length > 0) return Lyrics().error;
return Lyrics.fromPrivateJson(data['results']);
}
Future<SmartTrackList> smartTrackList(String id) async {
Map data = await callApi('deezer.pageSmartTracklist', params: {
'smarttracklist_id': id
});
return SmartTrackList.fromPrivateJson(data['results']['DATA'], songsJson: data['results']['SONGS']);
}
Future<List<Track>> flow() async {
Map data = await callApi('radio.getUserRadio', params: {
'user_id': userId
});
return data['results']['data'].map<Track>((json) => Track.fromPrivateJson(json)).toList();
}
//Get homepage/music library from deezer
Future<HomePage> homePage() async {
List grid = ['album', 'artist', 'channel', 'flow', 'playlist', 'radio', 'show', 'smarttracklist', 'track', 'user'];
Map data = await callApi('page.get', gatewayInput: jsonEncode({
"PAGE": "home",
"VERSION": "2.3",
"SUPPORT": {
/*
"deeplink-list": ["deeplink"],
"list": ["episode"],
"grid-preview-one": grid,
"grid-preview-two": grid,
"slideshow": grid,
"message": ["call_onboarding"],
*/
"grid": grid,
"horizontal-grid": grid,
"item-highlight": ["radio"],
"large-card": ["album", "playlist", "show", "video-link"],
"ads": [] //Nope
},
"LANG": "us",
"OPTIONS": []
}));
return HomePage.fromPrivateJson(data['results']);
}
//Log song listen to deezer
Future logListen(String trackId) async {
await callApi('log.listen', params: {'next_media': {'media': {'id': trackId, 'type': 'song'}}});
}
Future<HomePage> getChannel(String target) async {
List grid = ['album', 'artist', 'channel', 'flow', 'playlist', 'radio', 'show', 'smarttracklist', 'track', 'user'];
Map data = await callApi('page.get', gatewayInput: jsonEncode({
'PAGE': target,
"VERSION": "2.3",
"SUPPORT": {
/*
"deeplink-list": ["deeplink"],
"list": ["episode"],
"grid-preview-one": grid,
"grid-preview-two": grid,
"slideshow": grid,
"message": ["call_onboarding"],
*/
"grid": grid,
"horizontal-grid": grid,
"item-highlight": ["radio"],
"large-card": ["album", "playlist", "show", "video-link"],
"ads": [] //Nope
},
"LANG": "us",
"OPTIONS": []
}));
return HomePage.fromPrivateJson(data['results']);
}
//Add playlist to library
Future addPlaylist(String id) async {
await callApi('playlist.addFavorite', params: {
'parent_playlist_id': int.parse(id)
});
}
//Remove playlist from library
Future removePlaylist(String id) async {
await callApi('playlist.deleteFavorite', params: {
'playlist_id': int.parse(id)
});
}
//Delete playlist
Future deletePlaylist(String id) async {
await callApi('playlist.delete', params: {
'playlist_id': id
});
}
//Create playlist
//Status 1 - private, 2 - collaborative
Future createPlaylist(String title, {String description = "", int status = 1, List<String> trackIds = const []}) async {
Map data = await callApi('playlist.create', params: {
'title': title,
'description': description,
'songs': trackIds.map<List>((id) => [int.parse(id), trackIds.indexOf(id)]).toList(),
'status': status
});
//Return playlistId
return data['results'];
}
}

676
lib/api/definitions.dart Normal file
View file

@ -0,0 +1,676 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:intl/intl.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pointycastle/api.dart';
import 'package:pointycastle/block/aes_fast.dart';
import 'package:pointycastle/block/modes/ecb.dart';
import 'package:hex/hex.dart';
import 'package:path/path.dart' as p;
import 'package:crypto/crypto.dart' as crypto;
import 'dart:typed_data';
import 'dart:convert';
part 'definitions.g.dart';
@JsonSerializable()
class Track {
String id;
String title;
Album album;
List<Artist> artists;
Duration duration;
ImageDetails albumArt;
int trackNumber;
bool offline;
Lyrics lyrics;
bool favorite;
List<dynamic> playbackDetails;
Track({this.id, this.title, this.duration, this.album, this.playbackDetails, this.albumArt,
this.artists, this.trackNumber, this.offline, this.lyrics, this.favorite});
String get artistString => artists.map<String>((art) => art.name).join(', ');
String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
String getUrl(int quality) {
var md5 = crypto.md5;
int magic = 164;
List<int> _s1 = [
...utf8.encode(playbackDetails[0]),
magic,
...utf8.encode(quality.toString()),
magic,
...utf8.encode(id),
magic,
...utf8.encode(playbackDetails[1])
];
List<int> _s2 = [
...utf8.encode(HEX.encode(md5.convert(_s1).bytes)),
magic,
..._s1,
magic
];
while(_s2.length%16 > 0) _s2.add(46);
String _s3 = '';
BlockCipher cipher = ECBBlockCipher(AESFastEngine());
cipher.init(true, KeyParameter(Uint8List.fromList('jo6aey6haid2Teih'.codeUnits)));
for (int i=0; i<_s2.length/16; i++) {
_s3 += HEX.encode(cipher.process(Uint8List.fromList(_s2.sublist(i*16, i*16+16))));
}
return 'https://e-cdns-proxy-${playbackDetails[0][0]}.dzcdn.net/mobile/1/$_s3';
}
//MediaItem
MediaItem toMediaItem() => MediaItem(
title: this.title,
album: this.album.title,
artist: this.artists[0].name,
displayTitle: this.title,
displaySubtitle: this.artistString,
displayDescription: this.album.title,
artUri: this.albumArt.full,
duration: this.duration,
id: this.id,
extras: {
"playbackDetails": jsonEncode(this.playbackDetails),
"lyrics": jsonEncode(this.lyrics.toJson()),
"albumId": this.album.id,
"artists": jsonEncode(this.artists.map<Map>((art) => art.toJson()).toList())
}
);
factory Track.fromMediaItem(MediaItem mi) {
//Load album and artists.
//It is stored separately, to save id and other metadata
Album album = Album(title: mi.album);
List<Artist> artists = [Artist(name: mi.displaySubtitle??mi.artist)];
if (mi.extras != null) {
album.id = mi.extras['albumId'];
if (mi.extras['artists'] != null) {
artists = jsonDecode(mi.extras['artists']).map<Artist>((j) => Artist.fromJson(j)).toList();
}
}
return Track(
title: mi.title??mi.displayTitle,
artists: artists,
album: album,
id: mi.id,
albumArt: ImageDetails(fullUrl: mi.artUri),
duration: mi.duration,
playbackDetails: null, // So it gets updated from api
lyrics: Lyrics.fromJson(jsonDecode(((mi.extras??{})['lyrics'])??"{}"))
);
}
//JSON
factory Track.fromPrivateJson(Map<dynamic, dynamic> json, {bool favorite = false}) {
String title = json['SNG_TITLE'];
if (json['VERSION'] != null && json['VERSION'] != '') {
title = "${json['SNG_TITLE']} ${json['VERSION']}";
}
return Track(
id: json['SNG_ID'],
title: title,
duration: Duration(seconds: int.parse(json['DURATION'])),
albumArt: ImageDetails.fromPrivateString(json['ALB_PICTURE']),
album: Album.fromPrivateJson(json),
artists: (json['ARTISTS']??[json]).map<Artist>((dynamic art) =>
Artist.fromPrivateJson(art)).toList(),
trackNumber: int.parse((json['TRACK_NUMBER']??'0').toString()),
playbackDetails: [json['MD5_ORIGIN'], json['MEDIA_VERSION']],
lyrics: Lyrics(id: json['LYRICS_ID'].toString()),
favorite: favorite
);
}
Map<String, dynamic> toSQL({off = false}) => {
'id': id,
'title': title,
'album': album.id,
'artists': artists.map<String>((dynamic a) => a.id).join(','),
'duration': duration.inSeconds,
'albumArt': albumArt.full,
'trackNumber': trackNumber,
'offline': off?1:0,
'lyrics': jsonEncode(lyrics.toJson()),
'favorite': (favorite??0)?1:0
};
factory Track.fromSQL(Map<String, dynamic> data) => Track(
id: data['trackId']??data['id'], //If loading from downloads table
title: data['title'],
album: Album(id: data['album']),
duration: Duration(seconds: data['duration']),
albumArt: ImageDetails(fullUrl: data['albumArt']),
trackNumber: data['trackNumber'],
artists: List<Artist>.generate(data['artists'].split(',').length, (i) => Artist(
id: data['artists'].split(',')[i]
)),
offline: (data['offline'] == 1) ? true:false,
lyrics: Lyrics.fromJson(jsonDecode(data['lyrics'])),
favorite: (data['favorite'] == 1) ? true:false
);
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
Map<String, dynamic> toJson() => _$TrackToJson(this);
}
@JsonSerializable()
class Album {
String id;
String title;
List<Artist> artists;
List<Track> tracks;
ImageDetails art;
int fans;
bool offline; //If the album is offline, or just saved in db as metadata
bool library;
Album({this.id, this.title, this.art, this.artists, this.tracks, this.fans, this.offline, this.library});
String get artistString => artists.map<String>((art) => art.name).join(', ');
Duration get duration => Duration(seconds: tracks.fold(0, (v, t) => v += t.duration.inSeconds));
String get durationString => "${duration.inMinutes}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
String get fansString => NumberFormat.compact().format(fans);
//JSON
factory Album.fromPrivateJson(Map<dynamic, dynamic> json, {Map<dynamic, dynamic> songsJson = const {}, bool library = false}) => Album(
id: json['ALB_ID'],
title: json['ALB_TITLE'],
art: ImageDetails.fromPrivateString(json['ALB_PICTURE']),
artists: (json['ARTISTS']??[json]).map<Artist>((dynamic art) => Artist.fromPrivateJson(art)).toList(),
tracks: (songsJson['data']??[]).map<Track>((dynamic track) => Track.fromPrivateJson(track)).toList(),
fans: json['NB_FAN'],
library: library
);
Map<String, dynamic> toSQL({off = false}) => {
'id': id,
'title': title,
'artists': artists.map<String>((dynamic a) => a.id).join(','),
'tracks': tracks.map<String>((dynamic t) => t.id).join(','),
'art': art.full,
'fans': fans,
'offline': off?1:0,
'library': (library??false)?1:0
};
factory Album.fromSQL(Map<String, dynamic> data) => Album(
id: data['id'],
title: data['title'],
artists: List<Artist>.generate(data['artists'].split(',').length, (i) => Artist(
id: data['artists'].split(',')[i]
)),
tracks: List<Track>.generate(data['tracks'].split(',').length, (i) => Track(
id: data['tracks'].split(',')[i]
)),
art: ImageDetails(fullUrl: data['art']),
fans: data['fans'],
offline: (data['offline'] == 1) ? true:false,
library: (data['library'] == 1) ? true:false
);
factory Album.fromJson(Map<String, dynamic> json) => _$AlbumFromJson(json);
Map<String, dynamic> toJson() => _$AlbumToJson(this);
}
@JsonSerializable()
class Artist {
String id;
String name;
List<Album> albums;
int albumCount;
List<Track> topTracks;
ImageDetails picture;
int fans;
bool offline;
bool library;
Artist({this.id, this.name, this.albums, this.albumCount, this.topTracks, this.picture, this.fans, this.offline, this.library});
String get fansString => NumberFormat.compact().format(fans);
//JSON
factory Artist.fromPrivateJson(
Map<dynamic, dynamic> json, {
Map<dynamic, dynamic> albumsJson = const {},
Map<dynamic, dynamic> topJson = const {},
bool library = false
}) => Artist(
id: json['ART_ID'],
name: json['ART_NAME'],
fans: json['NB_FAN'],
picture: ImageDetails.fromPrivateString(json['ART_PICTURE'], type: 'artist'),
albumCount: albumsJson['total'],
albums: (albumsJson['data']??[]).map<Album>((dynamic data) => Album.fromPrivateJson(data)).toList(),
topTracks: (topJson['data']??[]).map<Track>((dynamic data) => Track.fromPrivateJson(data)).toList(),
library: library
);
Map<String, dynamic> toSQL({off = false}) => {
'id': id,
'name': name,
'albums': albums.map<String>((dynamic a) => a.id).join(','),
'topTracks': topTracks.map<String>((dynamic t) => t.id).join(','),
'picture': picture.full,
'fans': fans,
'albumCount': this.albumCount??(this.albums??[]).length,
'offline': off?1:0,
'library': (library??false)?1:0
};
factory Artist.fromSQL(Map<String, dynamic> data) => Artist(
id: data['id'],
name: data['name'],
topTracks: List<Track>.generate(data['topTracks'].split(',').length, (i) => Track(
id: data['topTracks'].split(',')[i]
)),
albums: List<Album>.generate(data['albums'].split(',').length, (i) => Album(
id: data['albums'].split(',')[i]
)),
albumCount: data['albumCount'],
picture: ImageDetails(fullUrl: data['picture']),
fans: data['fans'],
offline: (data['offline'] == 1)?true:false,
library: (data['library'] == 1)?true:false
);
factory Artist.fromJson(Map<String, dynamic> json) => _$ArtistFromJson(json);
Map<String, dynamic> toJson() => _$ArtistToJson(this);
}
@JsonSerializable()
class Playlist {
String id;
String title;
List<Track> tracks;
ImageDetails image;
Duration duration;
int trackCount;
User user;
int fans;
bool library;
String description;
Playlist({this.id, this.title, this.tracks, this.image, this.trackCount, this.duration, this.user, this.fans, this.library, this.description});
String get durationString => "${duration.inHours}:${duration.inMinutes.remainder(60).toString().padLeft(2, '0')}:${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}";
//JSON
factory Playlist.fromPrivateJson(Map<dynamic, dynamic> json, {Map<dynamic, dynamic> songsJson = const {}, bool library = false}) => Playlist(
id: json['PLAYLIST_ID'],
title: json['TITLE'],
trackCount: json['NB_SONG']??songsJson['total'],
image: ImageDetails.fromPrivateString(json['PLAYLIST_PICTURE'], type: 'playlist'),
fans: json['NB_FAN'],
duration: Duration(seconds: json['DURATION']??0),
description: json['DESCRIPTION'],
user: User(
id: json['PARENT_USER_ID'],
name: json['PARENT_USERNAME']??'',
picture: ImageDetails.fromPrivateString(json['PARENT_USER_PICTURE'], type: 'user')
),
tracks: (songsJson['data']??[]).map<Track>((dynamic data) => Track.fromPrivateJson(data)).toList(),
library: library
);
Map<String, dynamic> toSQL() => {
'id': id,
'title': title,
'tracks': tracks.map<String>((dynamic t) => t.id).join(','),
'image': image.full,
'duration': duration.inSeconds,
'userId': user.id,
'userName': user.name,
'fans': fans,
'description': description,
'library': (library??false)?1:0
};
factory Playlist.fromSQL(data) => Playlist(
id: data['id'],
title: data['title'],
description: data['description'],
tracks: List<Track>.generate(data['tracks'].split(',').length, (i) => Track(
id: data['tracks'].split(',')[i]
)),
image: ImageDetails(fullUrl: data['image']),
duration: Duration(seconds: data['duration']),
user: User(
id: data['userId'],
name: data['userName']
),
fans: data['fans'],
library: (data['library'] == 1)?true:false
);
factory Playlist.fromJson(Map<String, dynamic> json) => _$PlaylistFromJson(json);
Map<String, dynamic> toJson() => _$PlaylistToJson(this);
}
@JsonSerializable()
class User {
String id;
String name;
ImageDetails picture;
User({this.id, this.name, this.picture});
//Mostly handled by playlist
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
@JsonSerializable()
class ImageDetails {
String fullUrl;
String thumbUrl;
ImageDetails({this.fullUrl, this.thumbUrl});
//Get full/thumb with fallback
String get full => fullUrl??thumbUrl;
String get thumb => thumbUrl??fullUrl;
//JSON
factory ImageDetails.fromPrivateString(String art, {String type='cover'}) => ImageDetails(
fullUrl: 'https://e-cdns-images.dzcdn.net/images/$type/$art/1400x1400-000000-80-0-0.jpg',
thumbUrl: 'https://e-cdns-images.dzcdn.net/images/$type/$art/180x180-000000-80-0-0.jpg'
);
factory ImageDetails.fromPrivateJson(Map<dynamic, dynamic> json) => ImageDetails.fromPrivateString(
json['MD5'].split('-').first,
type: json['TYPE']
);
factory ImageDetails.fromJson(Map<String, dynamic> json) => _$ImageDetailsFromJson(json);
Map<String, dynamic> toJson() => _$ImageDetailsToJson(this);
}
class SearchResults {
List<Track> tracks;
List<Album> albums;
List<Artist> artists;
List<Playlist> playlists;
SearchResults({this.tracks, this.albums, this.artists, this.playlists});
//Check if no search results
bool get empty {
return ((tracks == null || tracks.length == 0) &&
(albums == null || albums.length == 0) &&
(artists == null || artists.length == 0) &&
(playlists == null || playlists.length == 0));
}
factory SearchResults.fromPrivateJson(Map<dynamic, dynamic> json) => SearchResults(
tracks: json['TRACK']['data'].map<Track>((dynamic data) => Track.fromPrivateJson(data)).toList(),
albums: json['ALBUM']['data'].map<Album>((dynamic data) => Album.fromPrivateJson(data)).toList(),
artists: json['ARTIST']['data'].map<Artist>((dynamic data) => Artist.fromPrivateJson(data)).toList(),
playlists: json['PLAYLIST']['data'].map<Playlist>((dynamic data) => Playlist.fromPrivateJson(data)).toList()
);
}
@JsonSerializable()
class Lyrics {
String id;
String writers;
List<Lyric> lyrics;
Lyrics({this.id, this.writers, this.lyrics});
Lyrics get error => Lyrics(
id: id,
writers: writers,
lyrics: [Lyric(
offset: Duration(milliseconds: 0),
text: 'Error loading lyrics!'
)]
);
//JSON
factory Lyrics.fromPrivateJson(Map<dynamic, dynamic> json) {
Lyrics l = Lyrics(
id: json['LYRICS_ID'],
writers: json['LYRICS_WRITERS'],
lyrics: json['LYRICS_SYNC_JSON'].map<Lyric>((l) => Lyric.fromPrivateJson(l)).toList()
);
//Clean empty lyrics
l.lyrics.removeWhere((l) => l.offset == null);
return l;
}
factory Lyrics.fromJson(Map<String, dynamic> json) => _$LyricsFromJson(json);
Map<String, dynamic> toJson() => _$LyricsToJson(this);
}
@JsonSerializable()
class Lyric {
Duration offset;
String text;
Lyric({this.offset, this.text});
//JSON
factory Lyric.fromPrivateJson(Map<dynamic, dynamic> json) {
if (json['milliseconds'] == null || json['line'] == null) return Lyric(); //Empty lyric
return Lyric(
offset: Duration(milliseconds: int.parse(json['milliseconds'].toString())),
text: json['line']
);
}
factory Lyric.fromJson(Map<String, dynamic> json) => _$LyricFromJson(json);
Map<String, dynamic> toJson() => _$LyricToJson(this);
}
@JsonSerializable()
class QueueSource {
String id;
String text;
String source;
QueueSource({this.id, this.text, this.source});
factory QueueSource.fromJson(Map<String, dynamic> json) => _$QueueSourceFromJson(json);
Map<String, dynamic> toJson() => _$QueueSourceToJson(this);
}
@JsonSerializable()
class SmartTrackList {
String id;
String title;
String subtitle;
String description;
int trackCount;
List<Track> tracks;
ImageDetails cover;
SmartTrackList({this.id, this.title, this.description, this.trackCount, this.tracks, this.cover, this.subtitle});
//JSON
factory SmartTrackList.fromPrivateJson(Map<dynamic, dynamic> json, {Map<dynamic, dynamic> songsJson = const {}}) => SmartTrackList(
id: json['SMARTTRACKLIST_ID'],
title: json['TITLE'],
subtitle: json['SUBTITLE'],
description: json['DESCRIPTION'],
trackCount: json['NB_SONG']??(songsJson['total']),
tracks: (songsJson['data']??[]).map<Track>((t) => Track.fromPrivateJson(t)).toList(),
cover: ImageDetails.fromPrivateJson(json['COVER'])
);
factory SmartTrackList.fromJson(Map<String, dynamic> json) => _$SmartTrackListFromJson(json);
Map<String, dynamic> toJson() => _$SmartTrackListToJson(this);
}
@JsonSerializable()
class HomePage {
List<HomePageSection> sections;
HomePage({this.sections});
//Save/Load
Future<String> _getPath() async {
Directory d = await getApplicationDocumentsDirectory();
return p.join(d.path, 'homescreen.json');
}
Future exists() async {
String path = await _getPath();
return await File(path).exists();
}
Future save() async {
String path = await _getPath();
await File(path).writeAsString(jsonEncode(this.toJson()));
}
Future<HomePage> load() async {
String path = await _getPath();
Map data = jsonDecode(await File(path).readAsString());
return HomePage.fromJson(data);
}
//JSON
factory HomePage.fromPrivateJson(Map<dynamic, dynamic> json) {
HomePage hp = HomePage(sections: []);
//Parse every section
for (var s in (json['sections']??[])) {
HomePageSection section = HomePageSection.fromPrivateJson(s);
if (section != null) hp.sections.add(section);
}
return hp;
}
factory HomePage.fromJson(Map<String, dynamic> json) => _$HomePageFromJson(json);
Map<String, dynamic> toJson() => _$HomePageToJson(this);
}
@JsonSerializable()
class HomePageSection {
String title;
HomePageSectionLayout layout;
@JsonKey(fromJson: _homePageItemFromJson, toJson: _homePageItemToJson)
List<HomePageItem> items;
HomePageSection({this.layout, this.items, this.title});
//JSON
factory HomePageSection.fromPrivateJson(Map<dynamic, dynamic> json) {
HomePageSection hps = HomePageSection(title: json['title'], items: []);
String layout = json['layout'];
//No ads there
if (layout == 'ads') return null;
if (layout == 'horizontal-grid' || layout == 'grid') {
hps.layout = HomePageSectionLayout.ROW;
} else {
//Currently only row layout
return null;
}
//Parse items
for (var i in (json['items']??[])) {
HomePageItem hpi = HomePageItem.fromPrivateJson(i);
if (hpi != null) hps.items.add(hpi);
}
return hps;
}
factory HomePageSection.fromJson(Map<String, dynamic> json) => _$HomePageSectionFromJson(json);
Map<String, dynamic> toJson() => _$HomePageSectionToJson(this);
static _homePageItemFromJson(json) => json.map<HomePageItem>((d) => HomePageItem.fromJson(d)).toList();
static _homePageItemToJson(items) => items.map((i) => i.toJson()).toList();
}
class HomePageItem {
HomePageItemType type;
dynamic value;
HomePageItem({this.type, this.value});
factory HomePageItem.fromPrivateJson(Map<dynamic, dynamic> json) {
String type = json['type'];
switch (type) {
//Smart Track List
case 'flow':
case 'smarttracklist':
return HomePageItem(type: HomePageItemType.SMARTTRACKLIST, value: SmartTrackList.fromPrivateJson(json['data']));
case 'playlist':
return HomePageItem(type: HomePageItemType.PLAYLIST, value: Playlist.fromPrivateJson(json['data']));
case 'artist':
return HomePageItem(type: HomePageItemType.ARTIST, value: Artist.fromPrivateJson(json['data']));
case 'channel':
return HomePageItem(type: HomePageItemType.CHANNEL, value: DeezerChannel.fromPrivateJson(json));
case 'album':
return HomePageItem(type: HomePageItemType.ALBUM, value: Album.fromPrivateJson(json['data']));
default:
return null;
}
}
factory HomePageItem.fromJson(Map<String, dynamic> json) {
String _t = json['type'];
switch (_t) {
case 'SMARTTRACKLIST':
return HomePageItem(type: HomePageItemType.SMARTTRACKLIST, value: SmartTrackList.fromJson(json['value']));
case 'PLAYLIST':
return HomePageItem(type: HomePageItemType.PLAYLIST, value: Playlist.fromJson(json['value']));
case 'ARTIST':
return HomePageItem(type: HomePageItemType.ARTIST, value: Artist.fromJson(json['value']));
case 'CHANNEL':
return HomePageItem(type: HomePageItemType.CHANNEL, value: DeezerChannel.fromJson(json['value']));
case 'ALBUM':
return HomePageItem(type: HomePageItemType.ALBUM, value: Album.fromJson(json['value']));
default:
return HomePageItem();
}
}
Map<String, dynamic> toJson() {
String type = this.type.toString().split('.').last;
return {'type': type, 'value': value.toJson()};
}
}
@JsonSerializable()
class DeezerChannel {
String id;
String target;
String title;
@JsonKey(fromJson: _colorFromJson, toJson: _colorToJson)
Color backgroundColor;
DeezerChannel({this.id, this.title, this.backgroundColor, this.target});
factory DeezerChannel.fromPrivateJson(Map<dynamic, dynamic> json) => DeezerChannel(
id: json['id'],
title: json['title'],
backgroundColor: Color(int.parse(json['background_color'].replaceFirst('#', 'FF'), radix: 16)),
target: json['target'].replaceFirst('/', '')
);
//JSON
static _colorToJson(Color c) => c.value;
static _colorFromJson(int v) => Color(v??Colors.blue.value);
factory DeezerChannel.fromJson(Map<String, dynamic> json) => _$DeezerChannelFromJson(json);
Map<String, dynamic> toJson() => _$DeezerChannelToJson(this);
}
enum HomePageItemType {
SMARTTRACKLIST,
PLAYLIST,
ARTIST,
CHANNEL,
ALBUM
}
enum HomePageSectionLayout {
ROW
}
enum RepeatType {
NONE,
LIST,
TRACK
}

338
lib/api/definitions.g.dart Normal file
View file

@ -0,0 +1,338 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'definitions.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Track _$TrackFromJson(Map<String, dynamic> json) {
return Track(
id: json['id'] as String,
title: json['title'] as String,
duration: json['duration'] == null
? null
: Duration(microseconds: json['duration'] as int),
album: json['album'] == null
? null
: Album.fromJson(json['album'] as Map<String, dynamic>),
playbackDetails: json['playbackDetails'] as List,
albumArt: json['albumArt'] == null
? null
: ImageDetails.fromJson(json['albumArt'] as Map<String, dynamic>),
artists: (json['artists'] as List)
?.map((e) =>
e == null ? null : Artist.fromJson(e as Map<String, dynamic>))
?.toList(),
trackNumber: json['trackNumber'] as int,
offline: json['offline'] as bool,
lyrics: json['lyrics'] == null
? null
: Lyrics.fromJson(json['lyrics'] as Map<String, dynamic>),
favorite: json['favorite'] as bool,
);
}
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
'id': instance.id,
'title': instance.title,
'album': instance.album,
'artists': instance.artists,
'duration': instance.duration?.inMicroseconds,
'albumArt': instance.albumArt,
'trackNumber': instance.trackNumber,
'offline': instance.offline,
'lyrics': instance.lyrics,
'favorite': instance.favorite,
'playbackDetails': instance.playbackDetails,
};
Album _$AlbumFromJson(Map<String, dynamic> json) {
return Album(
id: json['id'] as String,
title: json['title'] as String,
art: json['art'] == null
? null
: ImageDetails.fromJson(json['art'] as Map<String, dynamic>),
artists: (json['artists'] as List)
?.map((e) =>
e == null ? null : Artist.fromJson(e as Map<String, dynamic>))
?.toList(),
tracks: (json['tracks'] as List)
?.map(
(e) => e == null ? null : Track.fromJson(e as Map<String, dynamic>))
?.toList(),
fans: json['fans'] as int,
offline: json['offline'] as bool,
library: json['library'] as bool,
);
}
Map<String, dynamic> _$AlbumToJson(Album instance) => <String, dynamic>{
'id': instance.id,
'title': instance.title,
'artists': instance.artists,
'tracks': instance.tracks,
'art': instance.art,
'fans': instance.fans,
'offline': instance.offline,
'library': instance.library,
};
Artist _$ArtistFromJson(Map<String, dynamic> json) {
return Artist(
id: json['id'] as String,
name: json['name'] as String,
albums: (json['albums'] as List)
?.map(
(e) => e == null ? null : Album.fromJson(e as Map<String, dynamic>))
?.toList(),
albumCount: json['albumCount'] as int,
topTracks: (json['topTracks'] as List)
?.map(
(e) => e == null ? null : Track.fromJson(e as Map<String, dynamic>))
?.toList(),
picture: json['picture'] == null
? null
: ImageDetails.fromJson(json['picture'] as Map<String, dynamic>),
fans: json['fans'] as int,
offline: json['offline'] as bool,
library: json['library'] as bool,
);
}
Map<String, dynamic> _$ArtistToJson(Artist instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'albums': instance.albums,
'albumCount': instance.albumCount,
'topTracks': instance.topTracks,
'picture': instance.picture,
'fans': instance.fans,
'offline': instance.offline,
'library': instance.library,
};
Playlist _$PlaylistFromJson(Map<String, dynamic> json) {
return Playlist(
id: json['id'] as String,
title: json['title'] as String,
tracks: (json['tracks'] as List)
?.map(
(e) => e == null ? null : Track.fromJson(e as Map<String, dynamic>))
?.toList(),
image: json['image'] == null
? null
: ImageDetails.fromJson(json['image'] as Map<String, dynamic>),
trackCount: json['trackCount'] as int,
duration: json['duration'] == null
? null
: Duration(microseconds: json['duration'] as int),
user: json['user'] == null
? null
: User.fromJson(json['user'] as Map<String, dynamic>),
fans: json['fans'] as int,
library: json['library'] as bool,
description: json['description'] as String,
);
}
Map<String, dynamic> _$PlaylistToJson(Playlist instance) => <String, dynamic>{
'id': instance.id,
'title': instance.title,
'tracks': instance.tracks,
'image': instance.image,
'duration': instance.duration?.inMicroseconds,
'trackCount': instance.trackCount,
'user': instance.user,
'fans': instance.fans,
'library': instance.library,
'description': instance.description,
};
User _$UserFromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
name: json['name'] as String,
picture: json['picture'] == null
? null
: ImageDetails.fromJson(json['picture'] as Map<String, dynamic>),
);
}
Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'picture': instance.picture,
};
ImageDetails _$ImageDetailsFromJson(Map<String, dynamic> json) {
return ImageDetails(
fullUrl: json['fullUrl'] as String,
thumbUrl: json['thumbUrl'] as String,
);
}
Map<String, dynamic> _$ImageDetailsToJson(ImageDetails instance) =>
<String, dynamic>{
'fullUrl': instance.fullUrl,
'thumbUrl': instance.thumbUrl,
};
Lyrics _$LyricsFromJson(Map<String, dynamic> json) {
return Lyrics(
id: json['id'] as String,
writers: json['writers'] as String,
lyrics: (json['lyrics'] as List)
?.map(
(e) => e == null ? null : Lyric.fromJson(e as Map<String, dynamic>))
?.toList(),
);
}
Map<String, dynamic> _$LyricsToJson(Lyrics instance) => <String, dynamic>{
'id': instance.id,
'writers': instance.writers,
'lyrics': instance.lyrics,
};
Lyric _$LyricFromJson(Map<String, dynamic> json) {
return Lyric(
offset: json['offset'] == null
? null
: Duration(microseconds: json['offset'] as int),
text: json['text'] as String,
);
}
Map<String, dynamic> _$LyricToJson(Lyric instance) => <String, dynamic>{
'offset': instance.offset?.inMicroseconds,
'text': instance.text,
};
QueueSource _$QueueSourceFromJson(Map<String, dynamic> json) {
return QueueSource(
id: json['id'] as String,
text: json['text'] as String,
source: json['source'] as String,
);
}
Map<String, dynamic> _$QueueSourceToJson(QueueSource instance) =>
<String, dynamic>{
'id': instance.id,
'text': instance.text,
'source': instance.source,
};
SmartTrackList _$SmartTrackListFromJson(Map<String, dynamic> json) {
return SmartTrackList(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
trackCount: json['trackCount'] as int,
tracks: (json['tracks'] as List)
?.map(
(e) => e == null ? null : Track.fromJson(e as Map<String, dynamic>))
?.toList(),
cover: json['cover'] == null
? null
: ImageDetails.fromJson(json['cover'] as Map<String, dynamic>),
subtitle: json['subtitle'] as String,
);
}
Map<String, dynamic> _$SmartTrackListToJson(SmartTrackList instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'subtitle': instance.subtitle,
'description': instance.description,
'trackCount': instance.trackCount,
'tracks': instance.tracks,
'cover': instance.cover,
};
HomePage _$HomePageFromJson(Map<String, dynamic> json) {
return HomePage(
sections: (json['sections'] as List)
?.map((e) => e == null
? null
: HomePageSection.fromJson(e as Map<String, dynamic>))
?.toList(),
);
}
Map<String, dynamic> _$HomePageToJson(HomePage instance) => <String, dynamic>{
'sections': instance.sections,
};
HomePageSection _$HomePageSectionFromJson(Map<String, dynamic> json) {
return HomePageSection(
layout:
_$enumDecodeNullable(_$HomePageSectionLayoutEnumMap, json['layout']),
items: HomePageSection._homePageItemFromJson(json['items']),
title: json['title'] as String,
);
}
Map<String, dynamic> _$HomePageSectionToJson(HomePageSection instance) =>
<String, dynamic>{
'title': instance.title,
'layout': _$HomePageSectionLayoutEnumMap[instance.layout],
'items': HomePageSection._homePageItemToJson(instance.items),
};
T _$enumDecode<T>(
Map<T, dynamic> enumValues,
dynamic source, {
T unknownValue,
}) {
if (source == null) {
throw ArgumentError('A value must be provided. Supported values: '
'${enumValues.values.join(', ')}');
}
final value = enumValues.entries
.singleWhere((e) => e.value == source, orElse: () => null)
?.key;
if (value == null && unknownValue == null) {
throw ArgumentError('`$source` is not one of the supported values: '
'${enumValues.values.join(', ')}');
}
return value ?? unknownValue;
}
T _$enumDecodeNullable<T>(
Map<T, dynamic> enumValues,
dynamic source, {
T unknownValue,
}) {
if (source == null) {
return null;
}
return _$enumDecode<T>(enumValues, source, unknownValue: unknownValue);
}
const _$HomePageSectionLayoutEnumMap = {
HomePageSectionLayout.ROW: 'ROW',
};
DeezerChannel _$DeezerChannelFromJson(Map<String, dynamic> json) {
return DeezerChannel(
id: json['id'] as String,
title: json['title'] as String,
backgroundColor:
DeezerChannel._colorFromJson(json['backgroundColor'] as int),
target: json['target'] as String,
);
}
Map<String, dynamic> _$DeezerChannelToJson(DeezerChannel instance) =>
<String, dynamic>{
'id': instance.id,
'target': instance.target,
'title': instance.title,
'backgroundColor': DeezerChannel._colorToJson(instance.backgroundColor),
};

577
lib/api/download.dart Normal file
View file

@ -0,0 +1,577 @@
import 'package:disk_space/disk_space.dart';
import 'package:ext_storage/ext_storage.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:random_string/random_string.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:dio/dio.dart';
import 'package:filesize/filesize.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'dart:io';
import 'dart:async';
import 'deezer.dart';
import '../settings.dart';
import 'definitions.dart';
import '../ui/cached_image.dart';
DownloadManager downloadManager = DownloadManager();
MethodChannel platformChannel = const MethodChannel('f.f.freezer/native');
class DownloadManager {
Database db;
List<Download> queue = [];
String _offlinePath;
Future _download;
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin;
bool _cancelNotifications = true;
bool get stopped => queue.length > 0 && _download == null;
Future init() async {
//Prepare DB
String dir = await getDatabasesPath();
String path = p.join(dir, 'offline.db');
db = await openDatabase(
path,
version: 1,
onCreate: (Database db, int version) async {
Batch b = db.batch();
//Create tables
b.execute(""" CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT, url TEXT, private INTEGER, state INTEGER, trackId TEXT)""");
b.execute("""CREATE TABLE tracks (
id TEXT PRIMARY KEY, title TEXT, album TEXT, artists TEXT, duration INTEGER, albumArt TEXT, trackNumber INTEGER, offline INTEGER, lyrics TEXT, favorite INTEGER)""");
b.execute("""CREATE TABLE albums (
id TEXT PRIMARY KEY, title TEXT, artists TEXT, tracks TEXT, art TEXT, fans INTEGER, offline INTEGER, library INTEGER)""");
b.execute("""CREATE TABLE artists (
id TEXT PRIMARY KEY, name TEXT, albums TEXT, topTracks TEXT, picture TEXT, fans INTEGER, albumCount INTEGER, offline INTEGER, library INTEGER)""");
b.execute("""CREATE TABLE playlists (
id TEXT PRIMARY KEY, title TEXT, tracks TEXT, image TEXT, duration INTEGER, userId TEXT, userName TEXT, fans INTEGER, library INTEGER, description TEXT)""");
await b.commit();
}
);
//Prepare folders (/sdcard/Android/data/freezer/data/)
_offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
await Directory(_offlinePath).create(recursive: true);
//Notifications
await _prepareNotifications();
//Restore
List<Map> downloads = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 0");
downloads.forEach((download) => queue.add(Download.fromSQL(download, parseTrack: true)));
}
//Initialize flutter local notification plugin
Future _prepareNotifications() async {
flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
AndroidInitializationSettings androidInitializationSettings = AndroidInitializationSettings('@drawable/ic_logo');
InitializationSettings initializationSettings = InitializationSettings(androidInitializationSettings, null);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
}
//Show download progress notification, if now/total = null, show intermediate
Future _startProgressNotification() async {
_cancelNotifications = false;
Timer.periodic(Duration(milliseconds: 500), (timer) async {
//Cancel notifications
if (_cancelNotifications) {
flutterLocalNotificationsPlugin.cancel(10);
timer.cancel();
return;
}
//Not downloading
if (this.queue.length <= 0) return;
Download d = queue[0];
//Prepare and show notification
AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails(
'download', 'Download', 'Download',
importance: Importance.Default,
priority: Priority.Default,
showProgress: true,
maxProgress: d.total??1,
progress: d.received??1,
playSound: false,
enableVibration: false,
autoCancel: true,
//ongoing: true, //Allow dismissing
indeterminate: (d.total == null || d.total == d.received),
onlyAlertOnce: true
);
NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null);
await downloadManager.flutterLocalNotificationsPlugin.show(
10,
'Downloading: ${d.track.title}',
(d.state == DownloadState.POST) ? 'Post processing...' : '${filesize(d.received)} / ${filesize(d.total)} (${queue.length} in queue)',
notificationDetails
);
});
}
//Update queue, start new download
void updateQueue() async {
if (_download == null && queue.length > 0) {
_download = queue[0].download(
onDone: () async {
//On download finished
await db.rawUpdate('UPDATE downloads SET state = 1 WHERE trackId = ?', [queue[0].track.id]);
/*
if (queue[0].private) {
await db.rawUpdate('UPDATE downloads SET state = 1 WHERE trackId = ?', [queue[0].track.id]);
} else {
//Remove on db if public
await db.delete('downloads', where: 'trackId = ?', whereArgs: [queue[0].track.id]);
}
*/
queue.removeAt(0);
_download = null;
//Remove notification if no more downloads
if (queue.length == 0) {
_cancelNotifications = true;
}
updateQueue();
}
).catchError((err) async {
//Catch download errors
_download = null;
_cancelNotifications = true;
await _showError();
});
//Show download progress notifications
if (_cancelNotifications == null || _cancelNotifications) _startProgressNotification();
}
}
//Show error notification
Future _showError() async {
AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails(
'downloadError', 'Download Error', 'Download Error'
);
NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null);
flutterLocalNotificationsPlugin.show(
11, 'Error while downloading!', 'Please restart downloads in the library', notificationDetails
);
}
//Returns all offline tracks
Future<List<Track>> allOfflineTracks() async {
List data = await db.query('tracks', where: 'offline == 1');
List<Track> tracks = [];
//Load track data
for (var t in data) {
tracks.add(await getTrack(t['id']));
}
return tracks;
}
//Get all offline playlists
Future<List<Playlist>> getOfflinePlaylists() async {
List data = await db.query('playlists');
List<Playlist> playlists = [];
//Load playlists
for (var p in data) {
playlists.add(await getPlaylist(p['id']));
}
return playlists;
}
//Get playlist metadata with tracks
Future<Playlist> getPlaylist(String id) async {
if (id == null) return null;
List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]);
if (data.length == 0) return null;
//Load playlist tracks
Playlist p = Playlist.fromSQL(data[0]);
for (int i=0; i<p.tracks.length; i++) {
p.tracks[i] = await getTrack(p.tracks[i].id);
}
return p;
}
//Gets favorites
Future<Playlist> getFavorites() async {
return await getPlaylist('FAVORITES');
}
Future<List<Album>> getOfflineAlbums({List albumsData}) async {
//Load albums
if (albumsData == null) {
albumsData = await db.query('albums', where: 'offline == 1');
}
List<Album> albums = albumsData.map((alb) => Album.fromSQL(alb)).toList();
for(int i=0; i<albums.length; i++) {
albums[i].library = true;
//Load tracks
for(int j=0; j<albums[i].tracks.length; j++) {
albums[i].tracks[j] = await getTrack(albums[i].tracks[j].id, album: albums[i]);
}
//Load artists
List artistsData = await db.rawQuery('SELECT * FROM artists WHERE id IN (${albumsData[i]['artists']})');
albums[i].artists = artistsData.map<Artist>((a) => Artist.fromSQL(a)).toList();
}
return albums;
}
//Get track with metadata from db
Future<Track> getTrack(String id, {Album album, List<Artist> artists}) async {
List tracks = await db.query('tracks', where: 'id == ?', whereArgs: [id]);
if (tracks.length == 0) return null;
Track t = Track.fromSQL(tracks[0]);
//Load album from DB
t.album = album ?? Album.fromSQL((await db.query('albums', where: 'id == ?', whereArgs: [t.album.id]))[0]);
if (artists != null) {
t.artists = artists;
return t;
}
//Load artists from DB
for (int i=0; i<t.artists.length; i++) {
t.artists[i] = Artist.fromSQL(
(await db.query('artists', where: 'id == ?', whereArgs: [t.artists[i].id]))[0]);
}
return t;
}
Future removeOfflineTrack(String id) async {
//Check if track present in albums
List counter = await db.rawQuery('SELECT COUNT(*) FROM albums WHERE tracks LIKE "%$id%"');
if (counter[0]['COUNT(*)'] > 0) return;
//and in playlists
counter = await db.rawQuery('SELECT COUNT(*) FROM playlists WHERE tracks LIKE "%$id%"');
if (counter[0]['COUNT(*)'] > 0) return;
//Remove file
List download = await db.query('downloads', where: 'trackId == ?', whereArgs: [id]);
await File(download[0]['path']).delete();
//Delete from db
await db.delete('tracks', where: 'id == ?', whereArgs: [id]);
await db.delete('downloads', where: 'trackId == ?', whereArgs: [id]);
}
//Delete offline album
Future removeOfflineAlbum(String id) async {
List data = await db.rawQuery('SELECT * FROM albums WHERE id == ? AND offline == 1', [id]);
if (data.length == 0) return;
Map<String, dynamic> album = Map.from(data[0]); //make writable
//Remove DB
album['offline'] = 0;
await db.update('albums', album, where: 'id == ?', whereArgs: [id]);
//Get track ids
List<String> tracks = album['tracks'].split(',');
for (String t in tracks) {
//Remove tracks
await removeOfflineTrack(t);
}
}
Future removeOfflinePlaylist(String id) async {
List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]);
if (data.length == 0) return;
Playlist p = Playlist.fromSQL(data[0]);
//Remove db
await db.delete('playlists', where: 'id == ?', whereArgs: [id]);
//Remove tracks
for(Track t in p.tracks) {
await removeOfflineTrack(t.id);
}
}
//Get path to offline track
Future<String> getOfflineTrackPath(String id) async {
List<Map> tracks = await db.rawQuery('SELECT path FROM downloads WHERE state == 1 AND trackId == ?', [id]);
if (tracks.length < 1) {
return null;
}
Download d = Download.fromSQL(tracks[0]);
return d.path;
}
Future addOfflineTrack(Track track, {private = true}) async {
//Paths
String path = p.join(_offlinePath, track.id);
if (track.playbackDetails == null) {
//Get track from API if download info missing
track = await deezerAPI.track(track.id);
}
//Load lyrics
try {
Lyrics l = await deezerAPI.lyrics(track.id);
track.lyrics = l;
} catch (e) {}
String url = track.getUrl(settings.getQualityInt(settings.offlineQuality));
if (!private) {
//Check permissions
if (!(await Permission.storage.request().isGranted)) {
return;
}
//If saving to external
url = track.getUrl(settings.getQualityInt(settings.downloadQuality));
//Save just extension to path, will be generated before download
path = 'mp3';
if (settings.downloadQuality == AudioQuality.FLAC) {
path = 'flac';
}
}
Download download = Download(track: track, path: path, url: url, private: private);
//Database
Batch b = db.batch();
b.insert('downloads', download.toSQL());
b.insert('tracks', track.toSQL(off: false), conflictAlgorithm: ConflictAlgorithm.ignore);
if (private) {
//Duplicate check
List<Map> duplicate = await db.rawQuery('SELECT * FROM downloads WHERE trackId == ?', [track.id]);
if (duplicate.length != 0) return;
//Save art
await imagesDatabase.getImage(track.albumArt.full, permanent: true);
//Save to db
b.insert('tracks', track.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
b.insert('albums', track.album.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore);
track.artists.forEach((art) => b.insert('artists', art.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore));
}
await b.commit();
queue.add(download);
updateQueue();
}
Future addOfflineAlbum(Album album, {private = true}) async {
//Get full album from API if tracks are missing
if (album.tracks == null || album.tracks.length == 0) {
album = await deezerAPI.album(album.id);
}
//Update album in database
if (private) {
await db.insert('albums', album.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
}
//Save all tracks
for (Track track in album.tracks) {
await addOfflineTrack(track, private: private);
}
}
//Add offline playlist, can be also used as update
Future addOfflinePlaylist(Playlist playlist, {private = true}) async {
//Load full playlist if missing tracks
if (playlist.tracks == null || playlist.tracks.length != playlist.trackCount) {
playlist = await deezerAPI.fullPlaylist(playlist.id);
}
playlist.library = true;
//To DB
if (private) {
await db.insert('playlists', playlist.toSQL(), conflictAlgorithm: ConflictAlgorithm.replace);
}
//Download all tracks
for (Track t in playlist.tracks) {
await addOfflineTrack(t, private: private);
}
}
Future checkOffline({Album album, Track track, Playlist playlist}) async {
//Check if album/track (TODO: Artist, playlist) is offline
if (track != null) {
List res = await db.query('tracks', where: 'id == ? AND offline == 1', whereArgs: [track.id]);
if (res.length == 0) return false;
return true;
}
if (album != null) {
List res = await db.query('albums', where: 'id == ? AND offline == 1', whereArgs: [album.id]);
if (res.length == 0) return false;
return true;
}
if (playlist != null && playlist.id != null) {
List res = await db.query('playlists', where: 'id == ?', whereArgs: [playlist.id]);
if (res.length == 0) return false;
return true;
}
return false;
}
//Offline search
Future<SearchResults> search(String query) async {
SearchResults results = SearchResults(
tracks: [],
albums: [],
artists: [],
playlists: []
);
//Tracks
List tracksData = await db.rawQuery('SELECT * FROM tracks WHERE offline == 1 AND title like "%$query%"');
for (Map trackData in tracksData) {
results.tracks.add(await getTrack(trackData['id']));
}
//Albums
List albumsData = await db.rawQuery('SELECT * FROM albums WHERE offline == 1 AND title like "%$query%"');
results.albums = await getOfflineAlbums(albumsData: albumsData);
//Artists
//TODO: offline artists
//Playlists
List playlists = await db.rawQuery('SELECT * FROM playlists WHERE title like "%$query%"');
for (Map playlist in playlists) {
results.playlists.add(await getPlaylist(playlist['id']));
}
return results;
}
Future<List<Download>> getFinishedDownloads() async {
//Fetch from db
List<Map> data = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 1");
List<Download> downloads = data.map<Download>((d) => Download.fromSQL(d, parseTrack: true)).toList();
return downloads;
}
//Get stats for library screen
Future<List<String>> getStats() async {
//Get offline counts
int trackCount = (await db.rawQuery('SELECT COUNT(*) FROM tracks WHERE offline == 1'))[0]['COUNT(*)'];
int albumCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)'];
int playlistCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)'];
//Free space
double diskSpace = await DiskSpace.getFreeDiskSpace;
//Used space
List<FileSystemEntity> offlineStat = await Directory(_offlinePath).list().toList();
int offlineSize = 0;
for (var fs in offlineStat) {
offlineSize += (await fs.stat()).size;
}
//Return as a list, maybe refactor in future if feature stays
return ([
trackCount.toString(),
albumCount.toString(),
playlistCount.toString(),
filesize(offlineSize),
filesize((diskSpace * 1000000).floor())
]);
}
}
class Download {
Track track;
String path;
String url;
bool private;
DownloadState state;
String _cover;
int received = 0;
int total = 1;
Download({this.track, this.path, this.url, this.private, this.state = DownloadState.NONE});
Future download({onDone}) async {
Dio dio = Dio();
//TODO: Check for internet before downloading
if (!this.private) {
String ext = this.path;
//Get track details
this.track = await deezerAPI.track(track.id);
//Get path if public
RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]');
//Download path
if (settings.downloadFolderStructure) {
this.path = p.join(
settings.downloadPath ?? (await ExtStorage.getExternalStoragePublicDirectory(ExtStorage.DIRECTORY_MUSIC)),
track.artists[0].name.replaceAll(sanitize, ''),
track.album.title.replaceAll(sanitize, ''),
);
} else {
this.path = settings.downloadPath;
}
//Make dirs
await Directory(this.path).create(recursive: true);
//Grab cover
_cover = p.join(this.path, 'cover.jpg');
if (!settings.downloadFolderStructure) _cover = p.join(this.path, randomAlpha(12) + '_cover.jpg');
if (!await File(_cover).exists()) {
try {
await dio.download(
this.track.albumArt.full,
_cover,
);
} catch (e) {print('Error downloading cover');}
}
//Add filename
String _filename = '${track.trackNumber.toString().padLeft(2, '0')}. ${track.title.replaceAll(sanitize, "")}.$ext';
//Different naming types
if (settings.downloadNaming == DownloadNaming.STANDALONE)
_filename = '${track.artistString.replaceAll(sanitize, "")} - ${track.title.replaceAll(sanitize, "")}.$ext';
this.path = p.join(this.path, _filename);
}
//Download
this.state = DownloadState.DOWNLOADING;
await dio.download(
this.url,
this.path + '.ENC',
deleteOnError: true,
onReceiveProgress: (rec, total) {
this.received = rec;
this.total = total;
}
);
this.state = DownloadState.POST;
//Decrypt
await platformChannel.invokeMethod('decryptTrack', {'id': track.id, 'path': path});
//Tag
if (!private) {
//Tag track in native
await platformChannel.invokeMethod('tagTrack', {
'path': path,
'title': track.title,
'album': track.album.title,
'artists': track.artistString,
'artist': track.artists[0].name,
'cover': _cover,
'trackNumber': track.trackNumber
});
}
//Remove encrypted
await File(path + '.ENC').delete();
if (!settings.downloadFolderStructure) await File(_cover).delete();
this.state = DownloadState.DONE;
onDone();
return;
}
//JSON
Map<String, dynamic> toSQL() => {
'trackId': track.id,
'path': path,
'url': url,
'state': state == DownloadState.DONE ? 1:0,
'private': private?1:0
};
factory Download.fromSQL(Map<String, dynamic> data, {parseTrack = false}) => Download(
track: parseTrack?Track.fromSQL(data):Track(id: data['trackId']),
path: data['path'],
url: data['url'],
state: data['state'] == 1 ? DownloadState.DONE:DownloadState.NONE,
private: data['private'] == 1
);
}
enum DownloadState {
NONE,
DOWNLOADING,
POST,
DONE
}

657
lib/api/player.dart Normal file
View file

@ -0,0 +1,657 @@
import 'package:audio_service/audio_service.dart';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/ui/cached_image.dart';
import 'package:just_audio/just_audio.dart';
import 'package:connectivity/connectivity.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'definitions.dart';
import '../settings.dart';
import 'dart:io';
import 'dart:async';
import 'dart:convert';
PlayerHelper playerHelper = PlayerHelper();
class PlayerHelper {
StreamSubscription _customEventSubscription;
StreamSubscription _playbackStateStreamSubscription;
QueueSource queueSource;
RepeatType repeatType = RepeatType.NONE;
//Find queue index by id
int get queueIndex => AudioService.queue.indexWhere((mi) => mi.id == AudioService.currentMediaItem?.id??'Random string so it returns -1');
Future start() async {
//Subscribe to custom events
_customEventSubscription = AudioService.customEventStream.listen((event) async {
if (!(event is Map)) return;
if (event['action'] == 'onLoad') {
//After audio_service is loaded, load queue, set quality
await settings.updateAudioServiceQuality();
await AudioService.customAction('load');
return;
}
if (event['action'] == 'onRestore') {
//Load queueSource from isolate
this.queueSource = QueueSource.fromJson(event['queueSource']);
}
if (event['action'] == 'queueEnd') {
//If last song is played, load more queue
this.queueSource = QueueSource.fromJson(event['queueSource']);
print(queueSource.toJson());
return;
}
});
_playbackStateStreamSubscription = AudioService.playbackStateStream.listen((event) {
//Log song (if allowed)
if (event == null) return;
if (event.processingState == AudioProcessingState.ready && event.playing) {
if (settings.logListen) deezerAPI.logListen(AudioService.currentMediaItem.id);
}
});
//Start audio_service
_startService();
}
Future _startService() async {
if (AudioService.running) return;
await AudioService.start(
backgroundTaskEntrypoint: backgroundTaskEntrypoint,
androidEnableQueue: true,
androidStopForegroundOnPause: true,
androidNotificationOngoing: false,
androidNotificationClickStartsActivity: true,
androidNotificationChannelDescription: 'Freezer',
androidNotificationChannelName: 'Freezer',
androidNotificationIcon: 'drawable/ic_logo'
);
}
//Repeat toggle
Future changeRepeat() async {
//Change to next repeat type
switch (repeatType) {
case RepeatType.NONE:
repeatType = RepeatType.LIST; break;
case RepeatType.LIST:
repeatType = RepeatType.TRACK; break;
default:
repeatType = RepeatType.NONE; break;
}
//Set repeat type
await AudioService.customAction("repeatType", RepeatType.values.indexOf(repeatType));
}
//Executed before exit
Future onExit() async {
_customEventSubscription.cancel();
_playbackStateStreamSubscription.cancel();
}
//Replace queue, play specified track id
Future _loadQueuePlay(List<MediaItem> queue, String trackId) async {
await _startService();
await settings.updateAudioServiceQuality();
await AudioService.updateQueue(queue);
await AudioService.playFromMediaId(trackId);
}
//Play track from album
Future playFromAlbum(Album album, String trackId) async {
await playFromTrackList(album.tracks, trackId, QueueSource(
id: album.id,
text: album.title,
source: 'album'
));
}
//Play from artist top tracks
Future playFromTopTracks(List<Track> tracks, String trackId, Artist artist) async {
await playFromTrackList(tracks, trackId, QueueSource(
id: artist.id,
text: 'Top ${artist.name}',
source: 'topTracks'
));
}
Future playFromPlaylist(Playlist playlist, String trackId) async {
await playFromTrackList(playlist.tracks, trackId, QueueSource(
id: playlist.id,
text: playlist.title,
source: 'playlist'
));
}
//Load tracks as queue, play track id, set queue source
Future playFromTrackList(List<Track> tracks, String trackId, QueueSource queueSource) async {
await _startService();
List<MediaItem> queue = tracks.map<MediaItem>((track) => track.toMediaItem()).toList();
await setQueueSource(queueSource);
await _loadQueuePlay(queue, trackId);
}
//Load smart track list as queue, start from beginning
Future playFromSmartTrackList(SmartTrackList stl) async {
//Load from API if no tracks
if (stl.tracks == null || stl.tracks.length == 0) {
if (settings.offlineMode) {
Fluttertoast.showToast(
msg: "Offline mode, can't play flow/smart track lists.",
gravity: ToastGravity.BOTTOM,
toastLength: Toast.LENGTH_SHORT
);
return;
}
//Flow songs cannot be accessed by smart track list call
if (stl.id == 'flow') {
stl.tracks = await deezerAPI.flow();
} else {
stl = await deezerAPI.smartTrackList(stl.id);
}
}
QueueSource queueSource = QueueSource(
id: stl.id,
source: (stl.id == 'flow')?'flow':'smarttracklist',
text: stl.title
);
await playFromTrackList(stl.tracks, stl.tracks[0].id, queueSource);
}
Future setQueueSource(QueueSource queueSource) async {
await _startService();
this.queueSource = queueSource;
await AudioService.customAction('queueSource', queueSource.toJson());
}
}
void backgroundTaskEntrypoint() async {
AudioServiceBackground.run(() => AudioPlayerTask());
}
class AudioPlayerTask extends BackgroundAudioTask {
AudioPlayer _audioPlayer = AudioPlayer();
List<MediaItem> _queue = <MediaItem>[];
int _queueIndex = -1;
bool _playing;
bool _interrupted;
AudioProcessingState _skipState;
Duration _lastPosition;
ImagesDatabase imagesDB;
int mobileQuality;
int wifiQuality;
StreamSubscription _eventSub;
StreamSubscription _playerStateSub;
QueueSource queueSource;
int repeatType = 0;
MediaItem get mediaItem => _queue[_queueIndex];
//Controls
final playControl = MediaControl(
androidIcon: 'drawable/ic_play_arrow',
label: 'Play',
action: MediaAction.play
);
final pauseControl = MediaControl(
androidIcon: 'drawable/ic_pause',
label: 'Pause',
action: MediaAction.pause
);
final stopControl = MediaControl(
androidIcon: 'drawable/ic_stop',
label: 'Stop',
action: MediaAction.stop
);
final nextControl = MediaControl(
androidIcon: 'drawable/ic_skip_next',
label: 'Next',
action: MediaAction.skipToNext
);
final previousControl = MediaControl(
androidIcon: 'drawable/ic_skip_previous',
label: 'Previous',
action: MediaAction.skipToPrevious
);
@override
Future onStart(Map<String, dynamic> params) async {
_playerStateSub = _audioPlayer.playbackStateStream
.where((state) => state == AudioPlaybackState.completed)
.listen((_event) {
if (_queue.length > _queueIndex + 1) {
onSkipToNext();
return;
} else {
//Repeat whole list (if enabled)
if (repeatType == 1) {
_skip(-_queueIndex);
return;
}
//Ask for more tracks in queue
AudioServiceBackground.sendCustomEvent({
'action': 'queueEnd',
'queueSource': (queueSource??QueueSource()).toJson()
});
if (_playing) _playing = false;
_setState(AudioProcessingState.none);
return;
}
});
//Read audio player events
_eventSub = _audioPlayer.playbackEventStream.listen((event) {
AudioProcessingState bufferingState = event.buffering ? AudioProcessingState.buffering : null;
switch (event.state) {
case AudioPlaybackState.paused:
case AudioPlaybackState.playing:
_setState(bufferingState ?? AudioProcessingState.ready, pos: event.position);
break;
case AudioPlaybackState.connecting:
_setState(_skipState ?? AudioProcessingState.connecting, pos: event.position);
break;
default:
break;
}
});
//Initialize later
//await imagesDB.init();
AudioServiceBackground.setQueue(_queue);
AudioServiceBackground.sendCustomEvent({'action': 'onLoad'});
}
@override
Future onSkipToNext() {
//If repeating allowed
if (repeatType == 2) {
_skip(0);
return null;
}
_skip(1);
}
@override
Future onSkipToPrevious() => _skip(-1);
Future _skip(int offset) async {
int newPos = _queueIndex + offset;
//Out of bounds
if (newPos >= _queue.length || newPos < 0) return;
//First song
if (_playing == null) {
_playing = true;
} else if (_playing) {
await _audioPlayer.stop();
}
//Update position, album art source, queue source text
_queueIndex = newPos;
//Get uri
String uri = await _getTrackUri(mediaItem);
//Modify extras
Map<String, dynamic> extras = mediaItem.extras;
extras.addAll({"qualityString": await _getQualityString(uri, mediaItem.duration)});
_queue[_queueIndex] = mediaItem.copyWith(
artUri: await _getArtUri(mediaItem.artUri),
extras: extras
);
//Play
AudioServiceBackground.setMediaItem(mediaItem);
_skipState = offset > 0 ? AudioProcessingState.skippingToNext:AudioProcessingState.skippingToPrevious;
//Load
await _audioPlayer.setUrl(uri);
_skipState = null;
await _saveQueue();
(_playing??false) ? onPlay() : _setState(AudioProcessingState.ready);
}
@override
void onPlay() async {
//Start playing preloaded queue
if (AudioServiceBackground.state.processingState == AudioProcessingState.none && _queue.length > 0) {
if (_queueIndex < 0 || _queueIndex == null) {
await this._skip(1);
} else {
await this._skip(0);
}
//Restore position from saved queue
if (_lastPosition != null) {
onSeekTo(_lastPosition);
_lastPosition = null;
}
return;
}
if (_skipState == null) {
_playing = true;
_audioPlayer.play();
}
}
@override
void onPause() {
if (_skipState == null && _playing) {
_playing = false;
_audioPlayer.pause();
}
}
@override
void onSeekTo(Duration pos) {
_audioPlayer.seek(pos);
}
@override
void onClick(MediaButton button) {
if (_playing) onPause();
onPlay();
}
@override
Future onUpdateQueue(List<MediaItem> q) async {
this._queue = q;
AudioServiceBackground.setQueue(_queue);
await _saveQueue();
}
@override
void onPlayFromMediaId(String mediaId) async {
int pos = this._queue.indexWhere((mi) => mi.id == mediaId);
await _skip(pos - _queueIndex);
if (_playing == null || !_playing) onPlay();
}
@override
Future onFastForward() async {
await _seekRelative(fastForwardInterval);
}
@override
void onAddQueueItemAt(MediaItem mi, int index) {
_queue.insert(index, mi);
AudioServiceBackground.setQueue(_queue);
_saveQueue();
}
@override
void onAddQueueItem(MediaItem mi) {
_queue.add(mi);
AudioServiceBackground.setQueue(_queue);
_saveQueue();
}
@override
Future onRewind() async {
await _seekRelative(rewindInterval);
}
Future _seekRelative(Duration offset) async {
Duration newPos = _audioPlayer.playbackEvent.position + offset;
if (newPos < Duration.zero) newPos = Duration.zero;
if (newPos > mediaItem.duration) newPos = mediaItem.duration;
onSeekTo(_audioPlayer.playbackEvent.position + offset);
}
//Audio interruptions
@override
void onAudioFocusLost(AudioInterruption interruption) {
if (_playing) _interrupted = true;
switch (interruption) {
case AudioInterruption.pause:
case AudioInterruption.temporaryPause:
case AudioInterruption.unknownPause:
if (_playing) onPause();
break;
case AudioInterruption.temporaryDuck:
_audioPlayer.setVolume(0.5);
break;
}
}
@override
void onAudioFocusGained(AudioInterruption interruption) {
switch (interruption) {
case AudioInterruption.temporaryPause:
if (!_playing && _interrupted) onPlay();
break;
case AudioInterruption.temporaryDuck:
_audioPlayer.setVolume(1.0);
break;
default:
break;
}
_interrupted = false;
}
@override
void onAudioBecomingNoisy() {
onPause();
}
@override
Future onCustomAction(String name, dynamic args) async {
if (name == 'updateQuality') {
//Pass wifi & mobile quality by custom action
//Isolate can't access globals
this.wifiQuality = args['wifiQuality'];
this.mobileQuality = args['mobileQuality'];
}
if (name == 'saveQueue') {
await this._saveQueue();
}
//Load queue, called after start
if (name == 'load') {
await _loadQueue();
}
//Change queue source
if (name == 'queueSource') {
this.queueSource = QueueSource.fromJson(Map<String, dynamic>.from(args));
}
//Shuffle
if (name == 'shuffleQueue') {
MediaItem mi = mediaItem;
shuffle(this._queue);
_queueIndex = _queue.indexOf(mi);
AudioServiceBackground.setQueue(this._queue);
}
//Repeating
if (name == 'repeatType') {
this.repeatType = args;
}
return true;
}
Future<String> _getArtUri(String url) async {
//Load from cache
if (url.startsWith('http')) {
//Prepare db
if (imagesDB == null) {
imagesDB = ImagesDatabase();
await imagesDB.init();
}
String path = await imagesDB.getImage(url);
return 'file://$path';
}
//If file
if (url.startsWith('/')) return 'file://' + url;
return url;
}
Future<String> _getTrackUri(MediaItem mi) async {
String prefix = 'DEEZER|${mi.id}|';
//Check if song is available offline
String _offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
File f = File(p.join(_offlinePath, mi.id));
if (await f.exists()) return f.path;
//Get online url
Track t = Track(
id: mi.id,
playbackDetails: jsonDecode(mi.extras['playbackDetails']) //JSON Because of audio_service bug
);
ConnectivityResult conn = await Connectivity().checkConnectivity();
if (conn == ConnectivityResult.wifi) {
return prefix + t.getUrl(wifiQuality);
}
return prefix + t.getUrl(mobileQuality);
}
Future<String> _getQualityString(String uri, Duration duration) async {
//Get url/path
String url = uri;
List<String> split = uri.split('|');
if (split.length >= 3) url = split[2];
int size;
String format;
String source;
//Local file
if (url.startsWith('/')) {
//Read first 4 bytes of file, get format
File f = File(url);
Stream<List<int>> reader = f.openRead(0, 4);
List<int> magic = await reader.first;
format = _magicToFormat(magic);
size = await f.length();
source = 'Offline';
}
//URL
if (url.startsWith('http')) {
Dio dio = Dio();
Response response = await dio.head(url);
size = int.parse(response.headers['Content-Length'][0]);
//Parse format
format = response.headers['Content-Type'][0];
if (format.trim() == 'audio/mpeg') format = 'MP3';
if (format.trim() == 'audio/flac') format = 'FLAC';
source = 'Stream';
}
//Calculate
int bitrate = ((size / 125) / duration.inSeconds).floor();
return '$format ${bitrate}kbps ($source)';
}
//Magic number to string, source: https://en.wikipedia.org/wiki/List_of_file_signatures
String _magicToFormat(List<int> magic) {
Function eq = const ListEquality().equals;
if (eq(magic.sublist(0, 4), [0x66, 0x4c, 0x61, 0x43])) return 'FLAC';
//MP3 With ID3
if (eq(magic.sublist(0, 3), [0x49, 0x44, 0x33])) return 'MP3';
//MP3
List<int> m = magic.sublist(0, 2);
if (eq(m, [0xff, 0xfb]) ||eq(m, [0xff, 0xf3]) || eq(m, [0xff, 0xf2])) return 'MP3';
//Unknown
return 'UNK';
}
@override
void onTaskRemoved() async {
await _saveQueue();
onStop();
}
@override
Future onStop() async {
await _saveQueue();
if (_playing != null) _audioPlayer.stop();
if (_playerStateSub != null) _playerStateSub.cancel();
if (_eventSub != null) _eventSub.cancel();
await super.onStop();
}
@override
void onClose() async {
//await _saveQueue();
//Gets saved in onStop()
await onStop();
}
//Update state
void _setState(AudioProcessingState state, {Duration pos}) {
AudioServiceBackground.setState(
controls: _getControls(),
systemActions: (_playing == null) ? [] : [MediaAction.seekTo],
processingState: state ?? AudioServiceBackground.state.processingState,
playing: _playing ?? false,
position: pos ?? _audioPlayer.playbackEvent.position,
bufferedPosition: pos ?? _audioPlayer.playbackEvent.position,
speed: _audioPlayer.speed
);
}
List<MediaControl> _getControls() {
if (_playing == null || !_playing) {
//Paused / not-started
return [
previousControl,
playControl,
nextControl
];
}
//Playing
return [
previousControl,
pauseControl,
nextControl
];
}
//Get queue saved file path
Future<String> _getQueuePath() async {
Directory dir = await getApplicationDocumentsDirectory();
return p.join(dir.path, 'offline.json');
}
//Export queue to JSON
Future _saveQueue() async {
print('save');
File f = File(await _getQueuePath());
await f.writeAsString(jsonEncode({
'index': _queueIndex,
'queue': _queue.map<Map<String, dynamic>>((mi) => mi.toJson()).toList(),
'position': _audioPlayer.playbackEvent.position.inMilliseconds,
'queueSource': (queueSource??QueueSource()).toJson(),
}));
}
Future _loadQueue() async {
File f = File(await _getQueuePath());
if (await f.exists()) {
Map<String, dynamic> json = jsonDecode(await f.readAsString());
this._queue = (json['queue']??[]).map<MediaItem>((mi) => MediaItem.fromJson(mi)).toList();
this._queueIndex = json['index'] ?? -1;
this._lastPosition = Duration(milliseconds: json['position']??0);
this.queueSource = QueueSource.fromJson(json['queueSource']??{});
if (_queue != null) {
AudioServiceBackground.setQueue(_queue);
AudioServiceBackground.setMediaItem(mediaItem);
//Update state to allow play button in notification
this._setState(AudioProcessingState.none, pos: _lastPosition);
}
//Send restored queue source to ui
AudioServiceBackground.sendCustomEvent({'action': 'onRestore', 'queueSource': (queueSource??QueueSource()).toJson()});
return true;
}
}
}

175
lib/main.dart Normal file
View 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
View 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
View 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
View 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
View 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),
)
],
);
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()
),
),
),
),
)
);
}
}