0.6.10 - Spotify is ass

This commit is contained in:
exttex 2021-04-05 22:22:32 +02:00
parent 3105ed6c1d
commit 676d5d45cc
23 changed files with 1000 additions and 339 deletions

View File

@ -4,7 +4,6 @@ import 'package:freezer/api/spotify.dart';
import 'package:freezer/settings.dart'; import 'package:freezer/settings.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'dart:async'; import 'dart:async';
@ -149,12 +148,12 @@ class DeezerAPI {
try { try {
//Tracks //Tracks
if (uri.pathSegments[0] == 'track') { if (uri.pathSegments[0] == 'track') {
String id = await spotify.convertTrack(spotifyUri); String id = await SpotifyScrapper.convertTrack(spotifyUri);
return DeezerLinkResponse(type: DeezerLinkType.TRACK, id: id); return DeezerLinkResponse(type: DeezerLinkType.TRACK, id: id);
} }
//Albums //Albums
if (uri.pathSegments[0] == 'album') { if (uri.pathSegments[0] == 'album') {
String id = await spotify.convertAlbum(spotifyUri); String id = await SpotifyScrapper.convertAlbum(spotifyUri);
return DeezerLinkResponse(type: DeezerLinkType.ALBUM, id: id); return DeezerLinkResponse(type: DeezerLinkType.ALBUM, id: id);
} }
} catch (e) {} } catch (e) {}

171
lib/api/importer.dart Normal file
View File

@ -0,0 +1,171 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/download.dart';
Importer importer = Importer();
class Importer {
//Options
bool download = false;
//Preserve context
BuildContext context;
String title;
String description;
List<ImporterTrack> tracks;
String playlistId;
Playlist playlist;
bool done = false;
bool busy = false;
Future _future;
StreamController _streamController;
Stream get updateStream => _streamController.stream;
int get ok => tracks.fold(0, (v, t) => (t.state == TrackImportState.OK) ? v+1 : v);
int get error => tracks.fold(0, (v, t) => (t.state == TrackImportState.ERROR) ? v+1 : v);
Importer();
//Start importing wrapper
Future<void> start(BuildContext context, String title, String description, List<ImporterTrack> tracks) async {
//Save variables
this.playlist = null;
this.context = context;
this.title = title;
this.description = description??'';
this.tracks = tracks.map((t) {t.state = TrackImportState.NONE; return t;}).toList();
//Create playlist
playlistId = await deezerAPI.createPlaylist(title, description: description);
busy = true;
done = false;
_streamController = StreamController.broadcast();
_future = _start();
}
//Start importer
Future _start() async {
for (int i=0; i<tracks.length; i++) {
try {
String id = await _searchTrack(tracks[i]);
//Not found
if (id == null) {
tracks[i].state = TrackImportState.ERROR;
_streamController.add(tracks[i]);
continue;
}
//Add to playlist
await deezerAPI.addToPlaylist(id, playlistId);
tracks[i].state = TrackImportState.OK;
} catch (_) {
//Error occurred, mark as error
tracks[i].state = TrackImportState.ERROR;
}
_streamController.add(tracks[i]);
}
//Get full playlist
playlist = await deezerAPI.playlist(playlistId, nb: 10000);
playlist.library = true;
//Download
if (download) {
await downloadManager.addOfflinePlaylist(playlist, private: false, context: context);
}
//Mark as done
done = true;
busy = false;
//To update UI
_streamController.add(null);
_streamController.close();
}
//Find track on Deezer servers
Future<String> _searchTrack(ImporterTrack track) async {
//Try by ISRC
if (track.isrc != null && track.isrc.length == 12) {
Map deezer = await deezerAPI.callPublicApi('track/isrc:' + track.isrc);
if (deezer["id"] != null) {
return deezer["id"].toString();
}
}
//Search
String cleanedTitle = track.title.trim().toLowerCase().replaceAll("-", "").replaceAll("&", "").replaceAll("+", "");
SearchResults results = await deezerAPI.search("${track.artists[0]} $cleanedTitle");
for (Track t in results.tracks) {
//Match title
if (_cleanMatching(t.title) == _cleanMatching(track.title)) {
//Match artist
if (_matchArtists(track.artists, t.artists.map((a) => a.name))) {
return t.id;
}
}
}
}
//Clean title for matching
String _cleanMatching(String t) {
return t.toLowerCase()
.replaceAll(",", "")
.replaceAll("-", "")
.replaceAll(" ", "")
.replaceAll("&", "")
.replaceAll("+", "")
.replaceAll("/", "");
}
String _cleanArtist(String a) {
return a.toLowerCase()
.replaceAll(" ", "")
.replaceAll(",", "");
}
//Match at least 1 artist
bool _matchArtists(List<String> a, List<String> b) {
//Clean
List<String> _a = a.map(_cleanArtist).toList();
List<String> _b = b.map(_cleanArtist).toList();
for (String artist in _a) {
if (_b.contains(artist)) {
return true;
}
}
return false;
}
}
class ImporterTrack {
String title;
List<String> artists;
String isrc;
TrackImportState state;
ImporterTrack(this.title, this.artists, {this.isrc, this.state = TrackImportState.NONE});
}
enum TrackImportState {
NONE,
ERROR,
OK
}
extension TrackImportStateExtension on TrackImportState {
Widget get icon {
switch (this) {
case TrackImportState.ERROR:
return Icon(Icons.error, color: Colors.red,);
case TrackImportState.OK:
return Icon(Icons.done, color: Colors.green);
default:
return Container(width: 0, height: 0);
}
}
}

View File

@ -1,26 +1,18 @@
import 'package:flutter/material.dart';
import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/download.dart'; import 'package:freezer/api/importer.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/settings.dart';
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:html/dom.dart' as dom; import 'package:html/dom.dart' as dom;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:spotify/spotify.dart';
import 'dart:convert'; import 'dart:convert';
import 'dart:async'; import 'dart:io';
import 'package:url_launcher/url_launcher.dart';
SpotifyAPI spotify = SpotifyAPI(); class SpotifyScrapper {
class SpotifyAPI {
SpotifyPlaylist importingSpotifyPlaylist;
StreamController importingStream = StreamController.broadcast();
bool doneImporting;
//Parse spotify URL to URI (spotify:track:1234) //Parse spotify URL to URI (spotify:track:1234)
String parseUrl(String url) { static String parseUrl(String url) {
Uri uri = Uri.parse(url); Uri uri = Uri.parse(url);
if (uri.pathSegments.length > 3) return null; //Invalid URL if (uri.pathSegments.length > 3) return null; //Invalid URL
if (uri.pathSegments.length == 3) return 'spotify:${uri.pathSegments[1]}:${uri.pathSegments[2]}'; if (uri.pathSegments.length == 3) return 'spotify:${uri.pathSegments[1]}:${uri.pathSegments[2]}';
@ -29,16 +21,16 @@ class SpotifyAPI {
} }
//Get spotify embed url from uri //Get spotify embed url from uri
String getEmbedUrl(String uri) => 'https://embed.spotify.com/?uri=$uri'; static String getEmbedUrl(String uri) => 'https://embed.spotify.com/?uri=$uri';
//https://link.tospotify.com/ or https://spotify.app.link/ //https://link.tospotify.com/ or https://spotify.app.link/
Future resolveLinkUrl(String url) async { static Future resolveLinkUrl(String url) async {
http.Response response = await http.get(Uri.parse(url)); http.Response response = await http.get(Uri.parse(url));
Match match = RegExp(r'window\.top\.location = validate\("(.+)"\);').firstMatch(response.body); Match match = RegExp(r'window\.top\.location = validate\("(.+)"\);').firstMatch(response.body);
return match.group(1); return match.group(1);
} }
Future resolveUrl(String url) async { static Future resolveUrl(String url) async {
if (url.contains("link.tospotify") || url.contains("spotify.app.link")) { if (url.contains("link.tospotify") || url.contains("spotify.app.link")) {
return parseUrl(await resolveLinkUrl(url)); return parseUrl(await resolveLinkUrl(url));
} }
@ -46,7 +38,7 @@ class SpotifyAPI {
} }
//Extract JSON data form spotify embed page //Extract JSON data form spotify embed page
Future<Map> getEmbedData(String url) async { static Future<Map> getEmbedData(String url) async {
//Fetch //Fetch
http.Response response = await http.get(url); http.Response response = await http.get(url);
//Parse //Parse
@ -61,7 +53,7 @@ class SpotifyAPI {
} }
} }
Future<SpotifyPlaylist> playlist(String uri) async { static Future<SpotifyPlaylist> playlist(String uri) async {
//Load data //Load data
String url = getEmbedUrl(uri); String url = getEmbedUrl(uri);
Map data = await getEmbedData(url); Map data = await getEmbedData(url);
@ -71,7 +63,7 @@ class SpotifyAPI {
} }
//Get Deezer track ID from Spotify URI //Get Deezer track ID from Spotify URI
Future<String> convertTrack(String uri) async { static Future<String> convertTrack(String uri) async {
Map data = await getEmbedData(getEmbedUrl(uri)); Map data = await getEmbedData(getEmbedUrl(uri));
SpotifyTrack track = SpotifyTrack.fromJson(data); SpotifyTrack track = SpotifyTrack.fromJson(data);
Map deezer = await deezerAPI.callPublicApi('track/isrc:' + track.isrc); Map deezer = await deezerAPI.callPublicApi('track/isrc:' + track.isrc);
@ -79,75 +71,34 @@ class SpotifyAPI {
} }
//Get Deezer album ID by UPC //Get Deezer album ID by UPC
Future<String> convertAlbum(String uri) async { static Future<String> convertAlbum(String uri) async {
Map data = await getEmbedData(getEmbedUrl(uri)); Map data = await getEmbedData(getEmbedUrl(uri));
SpotifyAlbum album = SpotifyAlbum.fromJson(data); SpotifyAlbum album = SpotifyAlbum.fromJson(data);
Map deezer = await deezerAPI.callPublicApi('album/upc:' + album.upc); Map deezer = await deezerAPI.callPublicApi('album/upc:' + album.upc);
return deezer['id'].toString(); return deezer['id'].toString();
} }
Future convertPlaylist(SpotifyPlaylist playlist, {bool downloadOnly = false, BuildContext context, AudioQuality quality}) async {
doneImporting = false;
importingSpotifyPlaylist = playlist;
//Create Deezer playlist
String playlistId;
if (!downloadOnly)
playlistId = await deezerAPI.createPlaylist(playlist.name, description: playlist.description);
//Search for tracks
List<Track> downloadTracks = [];
for (SpotifyTrack track in playlist.tracks) {
Map deezer;
try {
//Search
deezer = await deezerAPI.callPublicApi('track/isrc:' + track.isrc);
if (deezer.containsKey('error')) throw Exception();
String id = deezer['id'].toString();
//Add
if (!downloadOnly)
await deezerAPI.addToPlaylist(id, playlistId);
if (downloadOnly)
downloadTracks.add(Track(id: id));
track.state = TrackImportState.OK;
} catch (e) {
//On error
track.state = TrackImportState.ERROR;
}
//Download
if (downloadOnly)
await downloadManager.addOfflinePlaylist(
Playlist(trackCount: downloadTracks.length, tracks: downloadTracks, title: playlist.name),
private: false,
quality: quality
);
//Add playlist id to stream, stream is for updating ui only
importingStream.add(playlistId);
importingSpotifyPlaylist = playlist;
}
doneImporting = true;
//Return DEEZER playlist id
return playlistId;
}
} }
class SpotifyTrack { class SpotifyTrack {
String title; String title;
String artists; List<String> artists;
String isrc; String isrc;
TrackImportState state = TrackImportState.NONE;
SpotifyTrack({this.title, this.artists, this.isrc}); SpotifyTrack({this.title, this.artists, this.isrc});
//JSON //JSON
factory SpotifyTrack.fromJson(Map json) => SpotifyTrack( factory SpotifyTrack.fromJson(Map json) => SpotifyTrack(
title: json['name'], title: json['name'],
artists: json['artists'].map((j) => j['name']).toList().join(', '), artists: json['artists'].map<String>((a) => a["name"].toString()).toList(),
isrc: json['external_ids']['isrc'] isrc: json['external_ids']['isrc']
); );
//Convert track to importer track
ImporterTrack toImporter() {
return ImporterTrack(title, artists, isrc: isrc);
}
} }
class SpotifyPlaylist { class SpotifyPlaylist {
@ -165,6 +116,11 @@ class SpotifyPlaylist {
image: (json['images'].length > 0) ? json['images'][0]['url'] : null, image: (json['images'].length > 0) ? json['images'][0]['url'] : null,
tracks: json['tracks']['items'].map<SpotifyTrack>((j) => SpotifyTrack.fromJson(j['track'])).toList() tracks: json['tracks']['items'].map<SpotifyTrack>((j) => SpotifyTrack.fromJson(j['track'])).toList()
); );
//Convert to importer tracks
List<ImporterTrack> toImporter() {
return tracks.map((t) => t.toImporter()).toList();
}
} }
class SpotifyAlbum { class SpotifyAlbum {
@ -178,8 +134,50 @@ class SpotifyAlbum {
); );
} }
enum TrackImportState {
NONE, class SpotifyAPIWrapper {
ERROR,
OK HttpServer _server;
SpotifyApi spotify;
User me;
Future authorize(String clientId, String clientSecret) async {
//Spotify
SpotifyApiCredentials credentials = SpotifyApiCredentials(clientId, clientSecret);
spotify = SpotifyApi(credentials);
//Create server
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 42069);
String responseUri;
//Get URL
final grant = SpotifyApi.authorizationCodeGrant(credentials);
final redirectUri = "http://localhost:42069";
final scopes = ['user-read-private', 'playlist-read-private', 'playlist-read-collaborative', 'user-library-read'];
final authUri = grant.getAuthorizationUrl(Uri.parse(redirectUri), scopes: scopes);
launch(authUri.toString());
//Wait for code
await for (HttpRequest request in _server) {
//Exit window
request.response.headers.set("Content-Type", "text/html; charset=UTF-8");
request.response.write("<body><h1>You can close this page and go back to Freezer.</h1></body><script>window.close();</script>");
request.response.close();
//Get token
if (request.uri.queryParameters["code"] != null) {
_server.close();
_server = null;
responseUri = request.uri.toString();
break;
}
}
//Create spotify
spotify = SpotifyApi.fromAuthCodeGrant(grant, responseUri);
me = await spotify.me.get();
}
//Cancel authorization
void cancelAuthorize() {
if (_server != null) {
_server.close(force: true);
_server = null;
}
}
} }

File diff suppressed because one or more lines are too long

View File

@ -351,6 +351,28 @@ const language_en_us = {
"Error logging in!": "Error logging in!", "Error logging in!": "Error logging in!",
"Change display mode": "Change display mode", "Change display mode": "Change display mode",
"Enable high refresh rates": "Enable high refresh rates", "Enable high refresh rates": "Enable high refresh rates",
"Display mode": "Display mode" "Display mode": "Display mode",
"Spotify v1": "Spotify v1",
"Import Spotify playlists up to 100 tracks without any login.": "Import Spotify playlists up to 100 tracks without any login.",
"Download imported tracks": "Download imported tracks",
"Start import": "Start import",
"Spotify v2": "Spotify v2",
"Import any Spotify playlist, import from own Spotify library. Requires free account.": "Import any Spotify playlist, import from own Spotify library. Requires free account.",
"Spotify Importer v2": "Spotify Importer v2",
"This importer requires Spotify Client ID and Client Secret. To obtain them:": "This importer requires Spotify Client ID and Client Secret. To obtain them:",
"1. Go to: developer.spotify.com/dashboard and create an app.": "1. Go to: developer.spotify.com/dashboard and create an app.",
"Open in Browser": "Open in Browser",
"2. In the app you just created go to settings, and set the Redirect URL to: ": "2. In the app you just created go to settings, and set the Redirect URL to: ",
"Copy the Redirect URL": "Copy the Redirect URL",
"Client ID": "Client ID",
"Client Secret": "Client Secret",
"Authorize": "Authorize",
"Logged in as: ": "Logged in as: ",
"Import playlists by URL": "Import playlists by URL",
"URL": "URL",
"Options": "Options",
"Invalid/Unsupported URL": "Invalid/Unsupported URL",
"Please wait...": "Please wait...",
"Login using email": "Login using email"
} }
}; };

View File

@ -177,8 +177,9 @@ class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateM
navigatorKey = GlobalKey<NavigatorState>(); navigatorKey = GlobalKey<NavigatorState>();
//Set display mode //Set display mode
if (settings.displayMode != null) { if (settings.displayMode != null && settings.displayMode >= 0) {
FlutterDisplayMode.supported.then((modes) async { FlutterDisplayMode.supported.then((modes) async {
if (modes.length - 1 >= settings.displayMode)
FlutterDisplayMode.setMode(modes[settings.displayMode]); FlutterDisplayMode.setMode(modes[settings.displayMode]);
}); });
} }
@ -368,19 +369,25 @@ class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateM
setState(() { setState(() {
_selected = s; _selected = s;
}); });
//Fix statusbar
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent
));
}, },
selectedItemColor: Theme.of(context).primaryColor, selectedItemColor: Theme.of(context).primaryColor,
items: <BottomNavigationBarItem>[ items: <BottomNavigationBarItem>[
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.home), icon: Icon(Icons.home),
title: Text('Home'.i18n)), label: 'Home'.i18n),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.search), icon: Icon(Icons.search),
title: Text('Search'.i18n), label: 'Search'.i18n,
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.library_music), icon: Icon(Icons.library_music),
title: Text('Library'.i18n)) label: 'Library'.i18n
)
], ],
) )
], ],

View File

@ -127,6 +127,12 @@ class Settings {
@JsonKey(defaultValue: null) @JsonKey(defaultValue: null)
String lastFMPassword; String lastFMPassword;
//Spotify
@JsonKey(defaultValue: null)
String spotifyClientId;
@JsonKey(defaultValue: null)
String spotifyClientSecret;
Settings({this.downloadPath, this.arl}); Settings({this.downloadPath, this.arl});
@ -213,8 +219,9 @@ class Settings {
case AudioQuality.MP3_128: return 1; case AudioQuality.MP3_128: return 1;
case AudioQuality.MP3_320: return 3; case AudioQuality.MP3_320: return 3;
case AudioQuality.FLAC: return 9; case AudioQuality.FLAC: return 9;
//Deezer default
default: return 8;
} }
return 8; //default
} }
//Check if is dark, can't use theme directly, because of system themes, and Theme.of(context).brightness broke //Check if is dark, can't use theme directly, because of system themes, and Theme.of(context).brightness broke
@ -229,9 +236,23 @@ class Settings {
static const deezerBg = Color(0xFF1F1A16); static const deezerBg = Color(0xFF1F1A16);
static const deezerBottom = Color(0xFF1b1714); static const deezerBottom = Color(0xFF1b1714);
TextTheme get _textTheme => (font == 'Deezer') ? null : GoogleFonts.getTextTheme(font); TextTheme get _textTheme => (font == 'Deezer')
? null
: GoogleFonts.getTextTheme(font, this.isDark ? ThemeData.dark().textTheme : ThemeData.light().textTheme);
String get _fontFamily => (font == 'Deezer') ? 'MabryPro' : null; String get _fontFamily => (font == 'Deezer') ? 'MabryPro' : null;
//Overrides for the non-deprecated buttons to look like the old ones
static final outlinedButtonTheme = OutlinedButtonThemeData(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Colors.white),
)
);
static final textButtonTheme = TextButtonThemeData(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(Colors.white),
)
);
Map<Themes, ThemeData> get _themeData => { Map<Themes, ThemeData> get _themeData => {
Themes.Light: ThemeData( Themes.Light: ThemeData(
textTheme: _textTheme, textTheme: _textTheme,
@ -242,6 +263,8 @@ class Settings {
sliderTheme: _sliderTheme, sliderTheme: _sliderTheme,
toggleableActiveColor: primaryColor, toggleableActiveColor: primaryColor,
bottomAppBarColor: Color(0xfff5f5f5), bottomAppBarColor: Color(0xfff5f5f5),
outlinedButtonTheme: outlinedButtonTheme,
textButtonTheme: textButtonTheme
), ),
Themes.Dark: ThemeData( Themes.Dark: ThemeData(
textTheme: _textTheme, textTheme: _textTheme,
@ -251,6 +274,8 @@ class Settings {
accentColor: primaryColor, accentColor: primaryColor,
sliderTheme: _sliderTheme, sliderTheme: _sliderTheme,
toggleableActiveColor: primaryColor, toggleableActiveColor: primaryColor,
outlinedButtonTheme: outlinedButtonTheme,
textButtonTheme: textButtonTheme
), ),
Themes.Deezer: ThemeData( Themes.Deezer: ThemeData(
textTheme: _textTheme, textTheme: _textTheme,
@ -267,7 +292,9 @@ class Settings {
bottomSheetTheme: BottomSheetThemeData( bottomSheetTheme: BottomSheetThemeData(
backgroundColor: deezerBottom backgroundColor: deezerBottom
), ),
cardColor: deezerBg cardColor: deezerBg,
outlinedButtonTheme: outlinedButtonTheme,
textButtonTheme: textButtonTheme
), ),
Themes.Black: ThemeData( Themes.Black: ThemeData(
textTheme: _textTheme, textTheme: _textTheme,
@ -283,7 +310,9 @@ class Settings {
toggleableActiveColor: primaryColor, toggleableActiveColor: primaryColor,
bottomSheetTheme: BottomSheetThemeData( bottomSheetTheme: BottomSheetThemeData(
backgroundColor: Colors.black, backgroundColor: Colors.black,
) ),
outlinedButtonTheme: outlinedButtonTheme,
textButtonTheme: textButtonTheme
) )
}; };

View File

@ -76,7 +76,9 @@ Settings _$SettingsFromJson(Map<String, dynamic> json) {
..logListen = json['logListen'] as bool ?? false ..logListen = json['logListen'] as bool ?? false
..proxyAddress = json['proxyAddress'] as String ..proxyAddress = json['proxyAddress'] as String
..lastFMUsername = json['lastFMUsername'] as String ..lastFMUsername = json['lastFMUsername'] as String
..lastFMPassword = json['lastFMPassword'] as String; ..lastFMPassword = json['lastFMPassword'] as String
..spotifyClientId = json['spotifyClientId'] as String
..spotifyClientSecret = json['spotifyClientSecret'] as String;
} }
Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{ Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
@ -119,6 +121,8 @@ Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
'proxyAddress': instance.proxyAddress, 'proxyAddress': instance.proxyAddress,
'lastFMUsername': instance.lastFMUsername, 'lastFMUsername': instance.lastFMUsername,
'lastFMPassword': instance.lastFMPassword, 'lastFMPassword': instance.lastFMPassword,
'spotifyClientId': instance.spotifyClientId,
'spotifyClientSecret': instance.spotifyClientSecret,
}; };
T _$enumDecode<T>( T _$enumDecode<T>(

View File

@ -114,7 +114,7 @@ class _ZoomableImageState extends State<ZoomableImage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ctx = context; ctx = context;
return FlatButton( return TextButton(
child: CachedImage( child: CachedImage(
url: widget.url, url: widget.url,
rounded: widget.rounded, rounded: widget.rounded,

View File

@ -21,7 +21,7 @@ import 'menu.dart';
class AlbumDetails extends StatefulWidget { class AlbumDetails extends StatefulWidget {
Album album; final Album album;
AlbumDetails(this.album, {Key key}): super(key: key); AlbumDetails(this.album, {Key key}): super(key: key);
@override @override
@ -165,7 +165,7 @@ class _AlbumDetailsState extends State<AlbumDetails> {
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[ children: <Widget>[
FlatButton( TextButton(
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
Icon((album.library??false)? Icons.favorite : Icons.favorite_border, size: 32), Icon((album.library??false)? Icons.favorite : Icons.favorite_border, size: 32),
@ -196,7 +196,7 @@ class _AlbumDetailsState extends State<AlbumDetails> {
}, },
), ),
MakeAlbumOffline(album: album), MakeAlbumOffline(album: album),
FlatButton( TextButton(
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
Icon(Icons.file_download, size: 32.0,), Icon(Icons.file_download, size: 32.0,),
@ -248,7 +248,7 @@ class _AlbumDetailsState extends State<AlbumDetails> {
class MakeAlbumOffline extends StatefulWidget { class MakeAlbumOffline extends StatefulWidget {
Album album; final Album album;
MakeAlbumOffline({Key key, this.album}): super(key: key); MakeAlbumOffline({Key key, this.album}): super(key: key);
@override @override
@ -399,7 +399,7 @@ class ArtistDetails extends StatelessWidget {
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[ children: <Widget>[
FlatButton( TextButton(
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
Icon(Icons.favorite, size: 32), Icon(Icons.favorite, size: 32),
@ -417,7 +417,7 @@ class ArtistDetails extends StatelessWidget {
}, },
), ),
if ((artist.radio??false)) if ((artist.radio??false))
FlatButton( TextButton(
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
Icon(Icons.radio, size: 32), Icon(Icons.radio, size: 32),
@ -714,7 +714,7 @@ class _DiscographyScreenState extends State<DiscographyScreen> {
class PlaylistDetails extends StatefulWidget { class PlaylistDetails extends StatefulWidget {
Playlist playlist; final Playlist playlist;
PlaylistDetails(this.playlist, {Key key}): super(key: key); PlaylistDetails(this.playlist, {Key key}): super(key: key);
@override @override

View File

@ -239,11 +239,11 @@ class DownloadTile extends StatelessWidget {
title: Text('Delete'.i18n), title: Text('Delete'.i18n),
content: Text('Are you sure you want to delete this download?'.i18n), content: Text('Are you sure you want to delete this download?'.i18n),
actions: [ actions: [
FlatButton( TextButton(
child: Text('Cancel'.i18n), child: Text('Cancel'.i18n),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
FlatButton( TextButton(
child: Text('Delete'.i18n), child: Text('Delete'.i18n),
onPressed: () async { onPressed: () async {
await downloadManager.removeDownload(download.id); await downloadManager.removeDownload(download.id);

View File

@ -50,6 +50,7 @@ class FreezerAppBar extends StatelessWidget implements PreferredSizeWidget {
return Theme( return Theme(
data: ThemeData(primaryColor: (Theme.of(context).brightness == Brightness.light)?Colors.white:Colors.black), data: ThemeData(primaryColor: (Theme.of(context).brightness == Brightness.light)?Colors.white:Colors.black),
child: AppBar( child: AppBar(
brightness: Theme.of(context).brightness,
elevation: 0.0, elevation: 0.0,
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
title: Text( title: Text(

View File

@ -180,7 +180,7 @@ class HomepageSectionWidget extends StatelessWidget {
//Has more items //Has more items
if (j == section.items.length) { if (j == section.items.length) {
if (section.hasMore ?? false) { if (section.hasMore ?? false) {
return FlatButton( return TextButton(
child: Text( child: Text(
'Show more'.i18n, 'Show more'.i18n,
textAlign: TextAlign.center, textAlign: TextAlign.center,

View File

@ -1,40 +1,44 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:freezer/api/deezer.dart'; import 'package:flutter/services.dart';
import 'package:freezer/api/definitions.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/download.dart';
import 'package:freezer/api/spotify.dart';
import 'package:freezer/settings.dart'; import 'package:freezer/settings.dart';
import 'package:freezer/ui/elements.dart';
import 'package:freezer/ui/menu.dart'; import 'package:freezer/ui/menu.dart';
import 'package:freezer/api/importer.dart';
import 'package:freezer/api/spotify.dart';
import 'package:freezer/ui/elements.dart';
import 'package:freezer/translations.i18n.dart'; import 'package:freezer/translations.i18n.dart';
import 'package:spotify/spotify.dart' as spotify;
import 'package:url_launcher/url_launcher.dart';
class ImporterScreen extends StatefulWidget { import 'dart:async';
class SpotifyImporterV1 extends StatefulWidget {
@override @override
_ImporterScreenState createState() => _ImporterScreenState(); _SpotifyImporterV1State createState() => _SpotifyImporterV1State();
} }
class _ImporterScreenState extends State<ImporterScreen> { class _SpotifyImporterV1State extends State<SpotifyImporterV1> {
String _url; String _url;
bool _error = false; bool _error = false;
bool _loading = false; bool _loading = false;
SpotifyPlaylist _data; SpotifyPlaylist _data;
//Load URL
Future _load() async { Future _load() async {
setState(() { setState(() {
_error = false; _error = false;
_loading = true; _loading = true;
}); });
try { try {
String uri = await spotify.resolveUrl(_url); String uri = await SpotifyScrapper.resolveUrl(_url);
//Error/NonPlaylist //Error/NonPlaylist
if (uri == null || uri.split(':')[1] != 'playlist') { if (uri == null || uri.split(':')[1] != 'playlist') {
throw Exception(); throw Exception();
} }
//Load //Load
SpotifyPlaylist data = await spotify.playlist(uri); SpotifyPlaylist data = await SpotifyScrapper.playlist(uri);
setState(() => _data = data); setState(() => _data = data);
return; return;
@ -46,7 +50,12 @@ class _ImporterScreenState extends State<ImporterScreen> {
}); });
return; return;
} }
}
//Start importing
Future _start() async {
List<ImporterTrack> tracks = _data.toImporter();
await importer.start(context, _data.name, _data.description, tracks);
} }
@override @override
@ -109,98 +118,96 @@ class _ImporterScreenState extends State<ImporterScreen> {
title: Text('Error loading URL!'.i18n), title: Text('Error loading URL!'.i18n),
leading: Icon(Icons.error, color: Colors.red,), leading: Icon(Icons.error, color: Colors.red,),
), ),
//Playlist
if (_data != null) if (_data != null)
ImporterWidget(_data) ...[
FreezerDivider(),
ListTile(
title: Text(_data.name),
subtitle: Text((_data.description ?? '') == '' ? '${_data.tracks.length} tracks' : _data.description),
leading: Image.network(_data.image??'http://cdn-images.deezer.com/images/cover//256x256-000000-80-0-0.jpg')
),
ImporterSettings(),
Padding(
padding: EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0),
child: ElevatedButton(
child: Text('Start import'.i18n),
onPressed: () async {
await _start();
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (context) => ImporterStatusScreen()
));
},
),
),
]
], ],
), ),
); );
} }
} }
class ImporterWidget extends StatefulWidget { class ImporterSettings extends StatefulWidget {
final SpotifyPlaylist playlist;
ImporterWidget(this.playlist, {Key key}): super(key: key);
@override @override
_ImporterWidgetState createState() => _ImporterWidgetState(); _ImporterSettingsState createState() => _ImporterSettingsState();
} }
class _ImporterWidgetState extends State<ImporterWidget> { class _ImporterSettingsState extends State<ImporterSettings> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: <Widget>[ mainAxisSize: MainAxisSize.min,
FreezerDivider(), children: [
ListTile( ListTile(
title: Text(widget.playlist.name), title: Text('Download imported tracks'.i18n),
subtitle: Text(widget.playlist.description), leading: Switch(
//Default image value: importer.download,
leading: Image.network(widget.playlist.image??'http://cdn-images.deezer.com/images/cover//256x256-000000-80-0-0.jpg'), onChanged: (v) => setState(() => importer.download = v),
), ),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[
RaisedButton(
child: Text('Convert'.i18n),
color: Theme.of(context).primaryColor,
onPressed: () {
spotify.convertPlaylist(widget.playlist);
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (context) => CurrentlyImportingScreen()
));
},
), ),
RaisedButton(
child: Text('Download only'.i18n),
color: Theme.of(context).primaryColor,
onPressed: () async {
//Ask for quality
AudioQuality quality;
if (settings.downloadQuality == AudioQuality.ASK) {
quality = await downloadManager.qualitySelect(context);
if (quality == null) return;
}
spotify.convertPlaylist(widget.playlist, downloadOnly: true, context: context, quality: quality);
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (context) => CurrentlyImportingScreen()
));
},
),
],
),
...List.generate(widget.playlist.tracks.length, (i) {
SpotifyTrack t = widget.playlist.tracks[i];
return ListTile(
title: Text(
t.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
t.artists,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
);
})
], ],
); );
} }
} }
class CurrentlyImportingScreen extends StatelessWidget { class ImporterStatusScreen extends StatefulWidget {
@override
Widget _stateIcon(TrackImportState s) { _ImporterStatusScreenState createState() => _ImporterStatusScreenState();
switch (s) {
case TrackImportState.ERROR:
return Icon(Icons.error, color: Colors.red,);
case TrackImportState.OK:
return Icon(Icons.done, color: Colors.green);
default:
return Container(width: 0, height: 0);
} }
class _ImporterStatusScreenState extends State<ImporterStatusScreen> {
bool _done = false;
StreamSubscription _subscription;
@override
void initState() {
//If import done mark as not done, to prevent double routing
if (importer.done) {
_done = true;
importer.done = false;
}
//Update
_subscription = importer.updateStream.listen((event) {
setState(() {
//Unset done so this page doesn't reopen
if (importer.done) {
_done = true;
importer.done = false;
};
});
});
super.initState();
}
@override
void dispose() {
if (_subscription != null)
_subscription.cancel();
super.dispose();
} }
@ -208,23 +215,11 @@ class CurrentlyImportingScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: FreezerAppBar('Importing...'.i18n), appBar: FreezerAppBar('Importing...'.i18n),
body: StreamBuilder( body: ListView(
stream: spotify.importingStream.stream, children: [
builder: (context, snapshot) { // Spinner
if (!_done)
//If not in progress Padding(
if (spotify.importingSpotifyPlaylist == null || spotify.importingSpotifyPlaylist.tracks == null || spotify.doneImporting == null)
return Center(child: CircularProgressIndicator(),);
if (spotify.doneImporting) spotify.doneImporting = null;
//Cont OK, error, total
int ok = spotify.importingSpotifyPlaylist.tracks.where((t) => t.state == TrackImportState.OK).length;
int err = spotify.importingSpotifyPlaylist.tracks.where((t) => t.state == TrackImportState.ERROR).length;
int count = spotify.importingSpotifyPlaylist.tracks.length;
return ListView(
children: <Widget>[
if (!(spotify.doneImporting??true)) Padding(
padding: EdgeInsets.symmetric(vertical: 8.0), padding: EdgeInsets.symmetric(vertical: 8.0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -233,6 +228,8 @@ class CurrentlyImportingScreen extends StatelessWidget {
], ],
), ),
), ),
// Progress indicator
Container( Container(
child: Row( child: Row(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
@ -243,7 +240,7 @@ class CurrentlyImportingScreen extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Icon(Icons.import_export, size: 24.0,), Icon(Icons.import_export, size: 24.0,),
Container(width: 4.0,), Container(width: 4.0,),
Text('${ok+err}/$count', style: TextStyle(fontSize: 24.0),) Text('${importer.ok+importer.error}/${importer.tracks.length}', style: TextStyle(fontSize: 24.0),)
], ],
), ),
Row( Row(
@ -251,7 +248,7 @@ class CurrentlyImportingScreen extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Icon(Icons.done, size: 24.0,), Icon(Icons.done, size: 24.0,),
Container(width: 4.0,), Container(width: 4.0,),
Text('$ok', style: TextStyle(fontSize: 24.0),) Text('${importer.ok}', style: TextStyle(fontSize: 24.0),)
], ],
), ),
Row( Row(
@ -259,17 +256,17 @@ class CurrentlyImportingScreen extends StatelessWidget {
children: <Widget>[ children: <Widget>[
Icon(Icons.error, size: 24.0,), Icon(Icons.error, size: 24.0,),
Container(width: 4.0,), Container(width: 4.0,),
Text('$err', style: TextStyle(fontSize: 24.0),), Text('${importer.error}', style: TextStyle(fontSize: 24.0),),
], ],
), ),
if (snapshot.data != null)
FlatButton( //When Done
if (_done)
TextButton(
child: Text('Playlist menu'.i18n), child: Text('Playlist menu'.i18n),
onPressed: () async { onPressed: () {
Playlist p = await deezerAPI.playlist(snapshot.data);
p.library = true;
MenuSheet m = MenuSheet(context); MenuSheet m = MenuSheet(context);
m.defaultPlaylistMenu(p); m.defaultPlaylistMenu(importer.playlist);
}, },
) )
], ],
@ -277,18 +274,391 @@ class CurrentlyImportingScreen extends StatelessWidget {
), ),
Container(height: 8.0), Container(height: 8.0),
FreezerDivider(), FreezerDivider(),
...List.generate(spotify.importingSpotifyPlaylist.tracks.length, (i) {
SpotifyTrack t = spotify.importingSpotifyPlaylist.tracks[i]; //Tracks
...List.generate(importer.tracks.length, (i) {
ImporterTrack t = importer.tracks[i];
return ListTile( return ListTile(
leading: t.state.icon,
title: Text(t.title), title: Text(t.title),
subtitle: Text(t.artists), subtitle: Text(
leading: _stateIcon(t.state), t.artists.join(", "),
maxLines: 1,
),
); );
}) })
], ],
);
},
), ),
); );
} }
} }
class SpotifyImporterV2 extends StatefulWidget {
@override
_SpotifyImporterV2State createState() => _SpotifyImporterV2State();
}
class _SpotifyImporterV2State extends State<SpotifyImporterV2> {
bool _authorizing = false;
String _clientId;
String _clientSecret;
SpotifyAPIWrapper spotify;
//Spotify authorization flow
Future _authorize() async {
setState(() => _authorizing = true);
spotify = SpotifyAPIWrapper();
await spotify.authorize(_clientId, _clientSecret);
//Save credentials
settings.spotifyClientId = _clientId;
settings.spotifyClientSecret = _clientSecret;
await settings.save();
setState(() => _authorizing = false);
//Redirect
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (context) => SpotifyImporterV2Main(spotify)
));
}
@override
void initState() {
_clientId = settings.spotifyClientId;
_clientSecret = settings.spotifyClientSecret;
super.initState();
}
@override
void dispose() {
//Stop server
if (spotify != null) {
spotify.cancelAuthorize();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FreezerAppBar("Spotify Importer v2".i18n),
body: ListView(
children: [
Padding(
padding: EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0),
child: Text(
"This importer requires Spotify Client ID and Client Secret. To obtain them:".i18n,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
"1. Go to: developer.spotify.com/dashboard and create an app.".i18n,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16.0,
),
)
),
Padding(
padding: EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0),
child: ElevatedButton(
child: Text("Open in Browser".i18n),
onPressed: () {
launch("https://developer.spotify.com/dashboard");
},
),
),
Container(height: 16.0),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
"2. In the app you just created go to settings, and set the Redirect URL to: ".i18n + "http://localhost:42069",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16.0,
),
)
),
Padding(
padding: EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0),
child: ElevatedButton(
child: Text("Copy the Redirect URL".i18n),
onPressed: () async {
await Clipboard.setData(new ClipboardData(text: "http://localhost:42069"));
Fluttertoast.showToast(msg: "Copied".i18n, gravity: ToastGravity.BOTTOM, toastLength: Toast.LENGTH_SHORT);
},
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
Flexible(
child: TextField(
controller: TextEditingController(text: _clientId),
decoration: InputDecoration(
labelText: "Client ID".i18n
),
onChanged: (v) => setState(() => _clientId = v),
),
),
Container(width: 16.0),
Flexible(
child: TextField(
controller: TextEditingController(text: _clientSecret),
obscureText: true,
decoration: InputDecoration(
labelText: "Client Secret".i18n
),
onChanged: (v) => setState(() => _clientSecret = v),
),
),
],
),
),
Padding(
padding: EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0),
child: ElevatedButton(
child: Text("Authorize".i18n),
onPressed: (_clientId != null && _clientSecret != null && !_authorizing)
? () => _authorize()
: null
),
),
if (_authorizing)
Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator()
],
),
)
],
),
);
}
}
class SpotifyImporterV2Main extends StatefulWidget {
SpotifyAPIWrapper spotify;
SpotifyImporterV2Main(this.spotify, {Key key}): super(key: key);
@override
_SpotifyImporterV2MainState createState() => _SpotifyImporterV2MainState();
}
class _SpotifyImporterV2MainState extends State<SpotifyImporterV2Main> {
String _url;
bool _urlLoading = false;
spotify.Playlist _urlPlaylist;
bool _playlistsLoading = true;
List<spotify.PlaylistSimple> _playlists;
@override
void initState() {
_loadPlaylists();
super.initState();
}
//Load playlists
Future _loadPlaylists() async {
var pages = widget.spotify.spotify.users.playlists(widget.spotify.me.id);
_playlists = List.from(await pages.all());
setState(() => _playlistsLoading = false);
}
Future _loadUrl() async {
setState(() => _urlLoading = true);
//Resolve URL
try {
String uri = await SpotifyScrapper.resolveUrl(_url);
//Error/NonPlaylist
if (uri == null || uri.split(':')[1] != 'playlist') {
throw Exception();
}
//Get playlist
spotify.Playlist playlist = await widget.spotify.spotify.playlists.get(uri.split(":")[2]);
setState(() {
_urlLoading = false;
_urlPlaylist = playlist;
});
} catch (e) {
Fluttertoast.showToast(msg: "Invalid/Unsupported URL".i18n, gravity: ToastGravity.BOTTOM, toastLength: Toast.LENGTH_SHORT);
setState(() => _urlLoading = false);
return;
}
}
Future _startImport(String title, String description, String id) async {
//Show loading dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => WillPopScope(
onWillPop: () => Future.value(false),
child: AlertDialog(
title: Text("Please wait...".i18n),
content: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [CircularProgressIndicator()],
)
)
)
);
try {
//Fetch entire playlist
var pages = widget.spotify.spotify.playlists.getTracksByPlaylistId(id);
var all = await pages.all();
//Map to importer track
List<ImporterTrack> tracks = all.map((t) => ImporterTrack(t.name, t.artists.map((a) => a.name).toList(), isrc: t.externalIds.isrc)).toList();
await importer.start(context, title, description, tracks);
//Route
Navigator.of(context).pop();
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (context) => ImporterStatusScreen()
));
} catch (e) {
Fluttertoast.showToast(msg: e.toString(), gravity: ToastGravity.BOTTOM, toastLength: Toast.LENGTH_SHORT);
Navigator.of(context).pop();
return;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: FreezerAppBar("Spotify Importer v2".i18n),
body: ListView(
children: [
Padding(
padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Text(
'Logged in as: '.i18n + widget.spotify.me.displayName,
maxLines: 1,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold
)
),
),
FreezerDivider(),
Container(height: 4.0),
Text(
"Options".i18n,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold
),
),
ImporterSettings(),
FreezerDivider(),
Container(height: 4.0),
Text(
"Import playlists by URL".i18n,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
children: [
Expanded(
child: TextField(
decoration: InputDecoration(
hintText: "URL".i18n
),
onChanged: (v) => setState(() => _url = v)
),
),
IconButton(
icon: Icon(Icons.search),
onPressed: () => _loadUrl(),
)
],
)
),
if (_urlLoading)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.symmetric(vertical: 4.0),
child: CircularProgressIndicator(),
)
],
),
if (_urlPlaylist != null)
ListTile(
title: Text(_urlPlaylist.name),
subtitle: Text(_urlPlaylist.description ?? ''),
leading: Image.network(_urlPlaylist.images.first?.url??"http://cdn-images.deezer.com/images/cover//256x256-000000-80-0-0.jpg")
),
if (_urlPlaylist != null)
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton(
child: Text("Import".i18n),
onPressed: () {
_startImport(_urlPlaylist.name, _urlPlaylist.description, _urlPlaylist.id);
}
)
),
// Playlists
FreezerDivider(),
Container(height: 4.0),
Text(
"Playlists".i18n,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold
)
),
Container(height: 4.0),
if (_playlistsLoading)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: EdgeInsets.symmetric(vertical: 4.0),
child: CircularProgressIndicator(),
)
],
),
if (!_playlistsLoading && _playlists != null)
...List.generate(_playlists.length, (i) {
spotify.PlaylistSimple p = _playlists[i];
return ListTile(
title: Text(p.name, maxLines: 1),
subtitle: Text(p.owner.displayName, maxLines: 1),
leading: Image.network(p.images.first?.url??"http://cdn-images.deezer.com/images/cover//256x256-000000-80-0-0.jpg"),
onTap: () {
_startImport(p.name, "", p.id);
},
);
})
],
)
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/cache.dart'; import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/importer.dart';
import 'package:freezer/api/player.dart'; import 'package:freezer/api/player.dart';
import 'package:freezer/settings.dart'; import 'package:freezer/settings.dart';
import 'package:freezer/ui/details_screens.dart'; import 'package:freezer/ui/details_screens.dart';
@ -138,16 +139,44 @@ class LibraryScreen extends StatelessWidget {
leading: LeadingIcon(Icons.import_export, color: Color(0xff2ba766)), leading: LeadingIcon(Icons.import_export, color: Color(0xff2ba766)),
subtitle: Text('Import playlists from Spotify'.i18n), subtitle: Text('Import playlists from Spotify'.i18n),
onTap: () { onTap: () {
if (spotify.doneImporting != null) { //Show progress
Navigator.of(context).push( if (importer.done || importer.busy) {
MaterialPageRoute(builder: (context) => CurrentlyImportingScreen()) Navigator.of(context).push(MaterialPageRoute(
); builder: (context) => ImporterStatusScreen()
if (spotify.doneImporting) spotify.doneImporting = null; ));
return; return;
} }
Navigator.of(context).push( //Pick importer dialog
MaterialPageRoute(builder: (context) => ImporterScreen()) showDialog(
context: context,
builder: (context) => SimpleDialog(
title: Text('Importer'.i18n),
children: [
ListTile (
leading: Icon(FontAwesome5.spotify),
title: Text('Spotify v1'.i18n),
subtitle: Text('Import Spotify playlists up to 100 tracks without any login.'.i18n),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => SpotifyImporterV1()
));
},
),
ListTile (
leading: Icon(FontAwesome5.spotify),
title: Text('Spotify v2'.i18n),
subtitle: Text('Import any Spotify playlist, import from own Spotify library. Requires free account.'.i18n),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => SpotifyImporterV2()
));
},
)
],
)
); );
}, },
), ),
@ -444,7 +473,7 @@ class _LibraryTracksState extends State<LibraryTracks> {
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: <Widget>[ children: <Widget>[
MakePlaylistOffline(_playlist), MakePlaylistOffline(_playlist),
FlatButton( TextButton(
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
Icon(Icons.file_download, size: 32.0,), Icon(Icons.file_download, size: 32.0,),

View File

@ -13,7 +13,7 @@ import 'home_screen.dart';
class LoginWidget extends StatefulWidget { class LoginWidget extends StatefulWidget {
Function callback; final Function callback;
LoginWidget({this.callback, Key key}): super(key: key); LoginWidget({this.callback, Key key}): super(key: key);
@override @override
@ -95,7 +95,7 @@ class _LoginWidgetState extends State<LoginWidget> {
], ],
), ),
actions: <Widget>[ actions: <Widget>[
FlatButton( TextButton(
child: Text('Dismiss'.i18n), child: Text('Dismiss'.i18n),
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
@ -169,14 +169,6 @@ class _LoginWidgetState extends State<LoginWidget> {
padding: EdgeInsets.symmetric(horizontal: 8.0), padding: EdgeInsets.symmetric(horizontal: 8.0),
child: ListView( child: ListView(
children: <Widget>[ children: <Widget>[
Container(height: 16.0,),
Text(
'Welcome to'.i18n,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16.0
),
),
FreezerTitle(), FreezerTitle(),
Container(height: 8.0,), Container(height: 8.0,),
Text( Text(
@ -190,7 +182,7 @@ class _LoginWidgetState extends State<LoginWidget> {
//Email login dialog //Email login dialog
Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0), padding: EdgeInsets.symmetric(horizontal: 32.0),
child: OutlineButton( child: OutlinedButton(
child: Text( child: Text(
'Login using email'.i18n, 'Login using email'.i18n,
), ),
@ -204,7 +196,7 @@ class _LoginWidgetState extends State<LoginWidget> {
), ),
Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0), padding: EdgeInsets.symmetric(horizontal: 32.0),
child: OutlineButton( child: OutlinedButton(
child: Text('Login using browser'.i18n), child: Text('Login using browser'.i18n),
onPressed: () { onPressed: () {
Navigator.of(context).push( Navigator.of(context).push(
@ -215,7 +207,7 @@ class _LoginWidgetState extends State<LoginWidget> {
), ),
Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0), padding: EdgeInsets.symmetric(horizontal: 32.0),
child: OutlineButton( child: OutlinedButton(
child: Text('Login using token'.i18n), child: Text('Login using token'.i18n),
onPressed: () { onPressed: () {
showDialog( showDialog(
@ -238,7 +230,7 @@ class _LoginWidgetState extends State<LoginWidget> {
), ),
), ),
actions: <Widget>[ actions: <Widget>[
FlatButton( TextButton(
child: Text('Save'.i18n), child: Text('Save'.i18n),
onPressed: () => goARL(null, _controller), onPressed: () => goARL(null, _controller),
) )
@ -259,7 +251,7 @@ class _LoginWidgetState extends State<LoginWidget> {
), ),
Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0), padding: EdgeInsets.symmetric(horizontal: 32.0),
child: OutlineButton( child: OutlinedButton(
child: Text('Open in browser'.i18n), child: Text('Open in browser'.i18n),
onPressed: () { onPressed: () {
InAppBrowser.openWithSystemBrowser(url: 'https://deezer.com/register'); InAppBrowser.openWithSystemBrowser(url: 'https://deezer.com/register');
@ -287,7 +279,7 @@ class _LoginWidgetState extends State<LoginWidget> {
class LoginBrowser extends StatelessWidget { class LoginBrowser extends StatelessWidget {
Function updateParent; final Function updateParent;
LoginBrowser(this.updateParent); LoginBrowser(this.updateParent);
@override @override
@ -331,7 +323,7 @@ class LoginBrowser extends StatelessWidget {
class EmailLogin extends StatefulWidget { class EmailLogin extends StatefulWidget {
Function callback; final Function callback;
EmailLogin(this.callback, {Key key}): super(key: key); EmailLogin(this.callback, {Key key}): super(key: key);
@override @override

View File

@ -697,14 +697,14 @@ class _SleepTimerDialogState extends State<SleepTimerDialog> {
], ],
), ),
actions: [ actions: [
FlatButton( TextButton(
child: Text('Dismiss'.i18n), child: Text('Dismiss'.i18n),
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
if (cache.sleepTimer != null) if (cache.sleepTimer != null)
FlatButton( TextButton(
child: Text('Cancel current timer'.i18n), child: Text('Cancel current timer'.i18n),
onPressed: () { onPressed: () {
cache.sleepTimer.cancel(); cache.sleepTimer.cancel();
@ -714,7 +714,7 @@ class _SleepTimerDialogState extends State<SleepTimerDialog> {
}, },
), ),
FlatButton( TextButton(
child: Text('Save'.i18n), child: Text('Save'.i18n),
onPressed: () { onPressed: () {
Duration duration = Duration(hours: hours, minutes: minutes); Duration duration = Duration(hours: hours, minutes: minutes);
@ -891,11 +891,11 @@ class _CreatePlaylistDialogState extends State<CreatePlaylistDialog> {
], ],
), ),
actions: <Widget>[ actions: <Widget>[
FlatButton( TextButton(
child: Text('Cancel'.i18n), child: Text('Cancel'.i18n),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
FlatButton( TextButton(
child: Text(edit ? 'Update'.i18n : 'Create'.i18n), child: Text(edit ? 'Update'.i18n : 'Create'.i18n),
onPressed: () async { onPressed: () async {
if (edit) { if (edit) {

View File

@ -152,36 +152,57 @@ class PrevNextButton extends StatelessWidget {
} }
class PlayPauseButton extends StatefulWidget {
class PlayPauseButton extends StatelessWidget {
final double size; final double size;
PlayPauseButton(this.size); PlayPauseButton(this.size, {Key key}): super(key: key);
@override
_PlayPauseButtonState createState() => _PlayPauseButtonState();
}
class _PlayPauseButtonState extends State<PlayPauseButton> with SingleTickerProviderStateMixin {
AnimationController _controller;
Animation<double> _animation;
@override
void initState() {
_controller = AnimationController(vsync: this, duration: Duration(milliseconds: 200));
_animation = Tween<double>(begin: 0, end: 1).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
super.initState();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StreamBuilder( return StreamBuilder(
stream: AudioService.playbackStateStream, stream: AudioService.playbackStateStream,
builder: (context, snapshot) { builder: (context, snapshot) {
//Playing //Animated icon by pato05
if (AudioService.playbackState?.playing??false) { bool _playing = AudioService.playbackState?.playing ?? false;
return IconButton( if (_playing || AudioService.playbackState?.processingState == AudioProcessingState.ready ||
iconSize: this.size, AudioService.playbackState?.processingState == AudioProcessingState.none) {
icon: Icon(Icons.pause), if (_playing)
onPressed: () => AudioService.pause() _controller.forward();
); else
} _controller.reverse();
//Paused
if ((!(AudioService.playbackState?.playing??false) &&
AudioService.playbackState.processingState == AudioProcessingState.ready) ||
//None state (stopped)
AudioService.playbackState.processingState == AudioProcessingState.none) {
return IconButton( return IconButton(
iconSize: this.size, splashRadius: widget.size,
icon: Icon(Icons.play_arrow), icon: AnimatedIcon(
onPressed: () => AudioService.play() icon: AnimatedIcons.play_pause,
progress: _animation,
),
iconSize: widget.size,
onPressed: _playing
? () => AudioService.pause()
: () => AudioService.play()
); );
} }
@ -190,12 +211,12 @@ class PlayPauseButton extends StatelessWidget {
case AudioProcessingState.error: case AudioProcessingState.error:
case AudioProcessingState.none: case AudioProcessingState.none:
case AudioProcessingState.stopped: case AudioProcessingState.stopped:
return Container(width: this.size, height: this.size); return Container(width: widget.size, height: widget.size);
//Loading, connecting, rewinding... //Loading, connecting, rewinding...
default: default:
return Container( return Container(
width: this.size, width: widget.size,
height: this.size, height: widget.size,
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
); );
} }
@ -203,3 +224,6 @@ class PlayPauseButton extends StatelessWidget {
); );
} }
} }

View File

@ -439,7 +439,7 @@ class _QualityInfoWidgetState extends State<QualityInfoWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FlatButton( return TextButton(
child: Text(value), child: Text(value),
onPressed: () { onPressed: () {
Navigator.of(context).push(MaterialPageRoute(builder: (context) => QualitySettings())); Navigator.of(context).push(MaterialPageRoute(builder: (context) => QualitySettings()));

View File

@ -106,7 +106,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: Text('Language'.i18n), title: Text('Language'.i18n),
content: Text('Language changed, please restart Freezer to apply!'.i18n), content: Text('Language changed, please restart Freezer to apply!'.i18n),
actions: [ actions: [
FlatButton( TextButton(
child: Text('OK'), child: Text('OK'),
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
@ -387,7 +387,7 @@ class _FontSelectorState extends State<FontSelector> {
title: Text('Warning'.i18n), title: Text('Warning'.i18n),
content: Text("This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!".i18n), content: Text("This app isn't made for supporting many fonts, it can break layouts and overflow. Use at your own risk!".i18n),
actions: [ actions: [
FlatButton( TextButton(
onPressed: () async { onPressed: () async {
setState(() => settings.font = font); setState(() => settings.font = font);
await settings.save(); await settings.save();
@ -398,7 +398,7 @@ class _FontSelectorState extends State<FontSelector> {
}, },
child: Text('Apply'.i18n), child: Text('Apply'.i18n),
), ),
FlatButton( TextButton(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
widget.callback(); widget.callback();
@ -701,11 +701,11 @@ class _DeezerSettingsState extends State<DeezerSettings> {
// ), // ),
// ), // ),
// actions: [ // actions: [
// FlatButton( // TextButton(
// child: Text('Cancel'.i18n), // child: Text('Cancel'.i18n),
// onPressed: () => Navigator.of(context).pop(), // onPressed: () => Navigator.of(context).pop(),
// ), // ),
// FlatButton( // TextButton(
// child: Text('Reset'.i18n), // child: Text('Reset'.i18n),
// onPressed: () async { // onPressed: () async {
// setState(() { // setState(() {
@ -715,7 +715,7 @@ class _DeezerSettingsState extends State<DeezerSettings> {
// Navigator.of(context).pop(); // Navigator.of(context).pop();
// }, // },
// ), // ),
// FlatButton( // TextButton(
// child: Text('Save'.i18n), // child: Text('Save'.i18n),
// onPressed: () async { // onPressed: () async {
// setState(() { // setState(() {
@ -739,8 +739,8 @@ class _DeezerSettingsState extends State<DeezerSettings> {
class FilenameTemplateDialog extends StatefulWidget { class FilenameTemplateDialog extends StatefulWidget {
String initial; final String initial;
Function onSave; final Function onSave;
FilenameTemplateDialog(this.initial, this.onSave, {Key key}): super(key: key); FilenameTemplateDialog(this.initial, this.onSave, {Key key}): super(key: key);
@override @override
@ -782,22 +782,22 @@ class _FilenameTemplateDialogState extends State<FilenameTemplateDialog> {
], ],
), ),
actions: [ actions: [
FlatButton( TextButton(
child: Text('Cancel'.i18n), child: Text('Cancel'.i18n),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
FlatButton( TextButton(
child: Text('Reset'.i18n), child: Text('Reset'.i18n),
onPressed: () { onPressed: () {
_controller.value = _controller.value.copyWith(text: '%artist% - %title%'); _controller.value = _controller.value.copyWith(text: '%artist% - %title%');
_new = '%artist% - %title%'; _new = '%artist% - %title%';
}, },
), ),
FlatButton( TextButton(
child: Text('Clear'.i18n), child: Text('Clear'.i18n),
onPressed: () => _controller.clear(), onPressed: () => _controller.clear(),
), ),
FlatButton( TextButton(
child: Text('Save'.i18n), child: Text('Save'.i18n),
onPressed: () async { onPressed: () async {
widget.onSave(_new); widget.onSave(_new);
@ -907,7 +907,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
title: Text('Warning'.i18n), title: Text('Warning'.i18n),
content: Text('Using too many concurrent downloads on older/weaker devices might cause crashes!'.i18n), content: Text('Using too many concurrent downloads on older/weaker devices might cause crashes!'.i18n),
actions: [ actions: [
FlatButton( TextButton(
child: Text('Dismiss'.i18n), child: Text('Dismiss'.i18n),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
) )
@ -1251,18 +1251,18 @@ class _GeneralSettingsState extends State<GeneralSettings> {
// content: Text('Due to plugin incompatibility, login using browser is unavailable without restart.'.i18n), // content: Text('Due to plugin incompatibility, login using browser is unavailable without restart.'.i18n),
content: Text('Restart of app is required to properly log out!'.i18n), content: Text('Restart of app is required to properly log out!'.i18n),
actions: <Widget>[ actions: <Widget>[
FlatButton( TextButton(
child: Text('Cancel'.i18n), child: Text('Cancel'.i18n),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
// FlatButton( // TextButton(
// child: Text('(ARL ONLY) Continue'.i18n), // child: Text('(ARL ONLY) Continue'.i18n),
// onPressed: () async { // onPressed: () async {
// await logOut(); // await logOut();
// Navigator.of(context).pop(); // Navigator.of(context).pop();
// }, // },
// ), // ),
FlatButton( TextButton(
child: Text('Log out & Exit'.i18n), child: Text('Log out & Exit'.i18n),
onPressed: () async { onPressed: () async {
try {AudioService.stop();} catch (e) {} try {AudioService.stop();} catch (e) {}
@ -1329,11 +1329,11 @@ class _LastFMLoginState extends State<LastFMLogin> {
], ],
), ),
actions: [ actions: [
FlatButton( TextButton(
child: Text('Cancel'.i18n), child: Text('Cancel'.i18n),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
FlatButton( TextButton(
child: Text('Login'.i18n), child: Text('Login'.i18n),
onPressed: () async { onPressed: () async {
LastFM last; LastFM last;
@ -1577,7 +1577,7 @@ class _CreditsScreenState extends State<CreditsScreen> {
subtitle: Text('To get latest releases'.i18n), subtitle: Text('To get latest releases'.i18n),
leading: Icon(FontAwesome5.telegram, color: Color(0xFF27A2DF), size: 36.0), leading: Icon(FontAwesome5.telegram, color: Color(0xFF27A2DF), size: 36.0),
onTap: () { onTap: () {
launch('https://t.me/freezereleases'); launch('https://t.me/joinchat/Se4zLEBvjS1NCiY9');
}, },
), ),
ListTile( ListTile(
@ -1601,7 +1601,7 @@ class _CreditsScreenState extends State<CreditsScreen> {
subtitle: Text('Source code, report issues there.'.i18n), subtitle: Text('Source code, report issues there.'.i18n),
leading: Icon(Icons.code, color: Colors.green, size: 36.0), leading: Icon(Icons.code, color: Colors.green, size: 36.0),
onTap: () { onTap: () {
launch('https://git.rip/freezer/'); launch('https://git.freezer.life/exttex/freezer');
}, },
), ),
ListTile( ListTile(

View File

@ -168,7 +168,7 @@ class _UpdaterScreenState extends State<UpdaterScreen> {
//Available download //Available download
if (_versionDownload != null) if (_versionDownload != null)
Column(children: [ Column(children: [
RaisedButton( ElevatedButton(
child: Text('Download'.i18n + ' (${_versionDownload.version})'), child: Text('Download'.i18n + ' (${_versionDownload.version})'),
onPressed: _buttonEnabled ? () { onPressed: _buttonEnabled ? () {
setState(() => _buttonEnabled = false); setState(() => _buttonEnabled = false);

View File

@ -625,6 +625,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
oauth2:
dependency: transitive
description:
name: oauth2
url: "https://pub.dartlang.org"
source: hosted
version: "1.6.3"
octo_image: octo_image:
dependency: transitive dependency: transitive
description: description:
@ -854,6 +861,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.8.0" version: "1.8.0"
spotify:
dependency: "direct main"
description:
name: spotify
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.1"
sprintf: sprintf:
dependency: transitive dependency: transitive
description: description:

View File

@ -27,6 +27,7 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
spotify: ^0.5.1
flutter_displaymode: ^0.1.1 flutter_displaymode: ^0.1.1
crypto: ^2.1.5 crypto: ^2.1.5
http: ^0.12.2 http: ^0.12.2