703 lines
22 KiB
Dart
703 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:fluttertoast/fluttertoast.dart';
|
|
import 'package:freezer/api/definitions.dart';
|
|
import 'package:freezer/settings.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:spotify/spotify.dart' as spotify;
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:url_launcher/url_launcher_string.dart';
|
|
|
|
class SpotifyImporterV1 extends StatefulWidget {
|
|
const SpotifyImporterV1({super.key});
|
|
|
|
@override
|
|
State<SpotifyImporterV1> createState() => _SpotifyImporterV1State();
|
|
}
|
|
|
|
class _SpotifyImporterV1State extends State<SpotifyImporterV1> {
|
|
late String _url;
|
|
bool _error = false;
|
|
bool _loading = false;
|
|
SpotifyPlaylist? _data;
|
|
|
|
//Load URL
|
|
Future _load() async {
|
|
setState(() {
|
|
_error = false;
|
|
_loading = true;
|
|
});
|
|
try {
|
|
String? uri = await SpotifyScrapper.resolveUrl(_url);
|
|
|
|
//Error/NonPlaylist
|
|
if (uri == null || uri.split(':')[1] != 'playlist') {
|
|
throw Exception();
|
|
}
|
|
//Load
|
|
SpotifyPlaylist data = await SpotifyScrapper.playlist(uri);
|
|
setState(() => _data = data);
|
|
return;
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = true;
|
|
_loading = false;
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
//Start importing
|
|
Future _start() async {
|
|
List<ImporterTrack> tracks = _data!.toImporter();
|
|
await importer.start(context, _data!.name, _data!.description, tracks);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text('Importer'.i18n)),
|
|
body: ListView(
|
|
children: <Widget>[
|
|
ListTile(
|
|
title: Text(
|
|
'Currently supporting only Spotify, with 100 tracks limit'
|
|
.i18n),
|
|
subtitle: Text('Due to API limitations'.i18n),
|
|
leading: const Icon(
|
|
Icons.warning,
|
|
color: Colors.deepOrangeAccent,
|
|
),
|
|
),
|
|
const ListTile(
|
|
title: Text('It\'s broken.'),
|
|
subtitle: Text('Use importer V2'),
|
|
leading: Icon(
|
|
Icons.warning,
|
|
color: Colors.deepOrangeAccent,
|
|
),
|
|
),
|
|
const FreezerDivider(),
|
|
Container(
|
|
height: 16.0,
|
|
),
|
|
Text(
|
|
'Enter your playlist link below'.i18n,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 20.0),
|
|
),
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
|
child: Row(
|
|
children: <Widget>[
|
|
Expanded(
|
|
child: TextField(
|
|
onChanged: (String s) => _url = s,
|
|
onSubmitted: (String s) {
|
|
_url = s;
|
|
_load();
|
|
},
|
|
decoration: const InputDecoration(hintText: 'URL'),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.search,
|
|
semanticLabel: "Search".i18n,
|
|
),
|
|
onPressed: () => _load(),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
Container(
|
|
height: 8.0,
|
|
),
|
|
|
|
if (_data == null && _loading)
|
|
const Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[CircularProgressIndicator()],
|
|
),
|
|
if (_error)
|
|
ListTile(
|
|
title: Text('Error loading URL!'.i18n),
|
|
leading: const Icon(
|
|
Icons.error,
|
|
color: Colors.red,
|
|
),
|
|
),
|
|
//Playlist
|
|
if (_data != null) ...[
|
|
const 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')),
|
|
const ImporterSettings(),
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0),
|
|
child: ElevatedButton(
|
|
child: Text('Start import'.i18n),
|
|
onPressed: () async {
|
|
final navigator = Navigator.of(context);
|
|
await _start();
|
|
navigator.pushReplacement(MaterialPageRoute(
|
|
builder: (context) => const ImporterStatusScreen()));
|
|
},
|
|
),
|
|
),
|
|
]
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ImporterSettings extends StatefulWidget {
|
|
const ImporterSettings({super.key});
|
|
|
|
@override
|
|
State<ImporterSettings> createState() => _ImporterSettingsState();
|
|
}
|
|
|
|
class _ImporterSettingsState extends State<ImporterSettings> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
title: Text('Download imported tracks'.i18n),
|
|
leading: Switch(
|
|
value: importer.download,
|
|
onChanged: (v) => setState(() => importer.download = v),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class ImporterStatusScreen extends StatefulWidget {
|
|
const ImporterStatusScreen({super.key});
|
|
|
|
@override
|
|
State<ImporterStatusScreen> createState() => _ImporterStatusScreenState();
|
|
}
|
|
|
|
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() {
|
|
_subscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text('Importing...'.i18n)),
|
|
body: ListView(
|
|
children: [
|
|
// Spinner
|
|
if (!_done)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[CircularProgressIndicator()],
|
|
),
|
|
),
|
|
|
|
// Progress indicator
|
|
Row(
|
|
mainAxisSize: MainAxisSize.max,
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: <Widget>[
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
const Icon(
|
|
Icons.import_export,
|
|
size: 24.0,
|
|
),
|
|
Container(
|
|
width: 4.0,
|
|
),
|
|
Text(
|
|
'${importer.ok + importer.error}/${importer.tracks.length}',
|
|
style: const TextStyle(fontSize: 24.0),
|
|
)
|
|
],
|
|
),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
const Icon(
|
|
Icons.done,
|
|
size: 24.0,
|
|
),
|
|
Container(
|
|
width: 4.0,
|
|
),
|
|
Text(
|
|
'${importer.ok}',
|
|
style: const TextStyle(fontSize: 24.0),
|
|
)
|
|
],
|
|
),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
const Icon(
|
|
Icons.error,
|
|
size: 24.0,
|
|
),
|
|
Container(
|
|
width: 4.0,
|
|
),
|
|
Text(
|
|
'${importer.error}',
|
|
style: const TextStyle(fontSize: 24.0),
|
|
),
|
|
],
|
|
),
|
|
|
|
//When Done
|
|
if (_done)
|
|
TextButton(
|
|
child: Text('Playlist menu'.i18n),
|
|
onPressed: () {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultPlaylistMenu(importer.playlist!);
|
|
},
|
|
)
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 8.0),
|
|
const FreezerDivider(),
|
|
|
|
//Tracks
|
|
...List.generate(importer.tracks.length, (i) {
|
|
ImporterTrack t = importer.tracks[i];
|
|
return ListTile(
|
|
leading: t.state.icon,
|
|
title: Text(t.title!),
|
|
subtitle: Text(
|
|
t.artists!.join(", "),
|
|
maxLines: 1,
|
|
),
|
|
);
|
|
})
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class SpotifyImporterV2 extends StatefulWidget {
|
|
const SpotifyImporterV2({super.key});
|
|
|
|
@override
|
|
State<SpotifyImporterV2> createState() => _SpotifyImporterV2State();
|
|
}
|
|
|
|
class _SpotifyImporterV2State extends State<SpotifyImporterV2> {
|
|
bool _authorizing = false;
|
|
String? _clientId;
|
|
String? _clientSecret;
|
|
final SpotifyAPIWrapper spotify = SpotifyAPIWrapper();
|
|
|
|
//Spotify authorization flow
|
|
Future _authorize() async {
|
|
setState(() => _authorizing = true);
|
|
await spotify.authorize(_clientId, _clientSecret);
|
|
//Save credentials
|
|
settings.spotifyClientId = _clientId;
|
|
settings.spotifyClientSecret = _clientSecret;
|
|
await settings.save();
|
|
setState(() => _authorizing = false);
|
|
//Redirect
|
|
if (context.mounted) {
|
|
Navigator.of(context).pushReplacement(MaterialPageRoute(
|
|
builder: (context) => SpotifyImporterV2Main(spotify)));
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
_clientId = settings.spotifyClientId;
|
|
_clientSecret = settings.spotifyClientSecret;
|
|
|
|
//Try saved
|
|
spotify.trySaved().then((r) {
|
|
if (r) {
|
|
Navigator.of(context).pushReplacement(MaterialPageRoute(
|
|
builder: (context) => SpotifyImporterV2Main(spotify)));
|
|
}
|
|
});
|
|
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
//Stop server
|
|
spotify.cancelAuthorize();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text("Spotify Importer v2".i18n)),
|
|
body: ListView(
|
|
children: [
|
|
Padding(
|
|
padding:
|
|
const 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: const TextStyle(
|
|
fontSize: 18.0,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
child: Text(
|
|
"1. Go to: developer.spotify.com/dashboard and create an app."
|
|
.i18n,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 16.0,
|
|
),
|
|
)),
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0),
|
|
child: ElevatedButton(
|
|
child: Text("Open in Browser".i18n),
|
|
onPressed: () {
|
|
launchUrlString("https://developer.spotify.com/dashboard");
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 16.0),
|
|
Padding(
|
|
padding: const 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: const TextStyle(
|
|
fontSize: 16.0,
|
|
),
|
|
)),
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0),
|
|
child: ElevatedButton(
|
|
child: Text("Copy the Redirect URL".i18n),
|
|
onPressed: () async {
|
|
await Clipboard.setData(
|
|
const ClipboardData(text: "http://localhost:42069"));
|
|
Fluttertoast.showToast(msg: "Copied".i18n);
|
|
},
|
|
),
|
|
),
|
|
Padding(
|
|
padding:
|
|
const 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),
|
|
),
|
|
),
|
|
const SizedBox(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:
|
|
const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0),
|
|
child: ElevatedButton(
|
|
onPressed: (_clientId != null &&
|
|
_clientSecret != null &&
|
|
!_authorizing)
|
|
? () => _authorize()
|
|
: null,
|
|
child: Text("Authorize".i18n)),
|
|
),
|
|
if (_authorizing)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 8.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [CircularProgressIndicator()],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class SpotifyImporterV2Main extends StatefulWidget {
|
|
final SpotifyAPIWrapper spotify;
|
|
const SpotifyImporterV2Main(this.spotify, {Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<SpotifyImporterV2Main> createState() => _SpotifyImporterV2MainState();
|
|
}
|
|
|
|
class _SpotifyImporterV2MainState extends State<SpotifyImporterV2Main> {
|
|
late 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);
|
|
setState(() => _urlLoading = false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
Future _startImport(String? title, String? description, String? id) async {
|
|
//Show loading dialog
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => PopScope(
|
|
canPop: false,
|
|
child: AlertDialog(
|
|
title: Text("Please wait...".i18n),
|
|
content: const 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();
|
|
if (!context.mounted) return;
|
|
await importer.start(context, title, description, tracks);
|
|
|
|
if (!context.mounted) return;
|
|
} catch (e, st) {
|
|
print(e);
|
|
print(st);
|
|
ScaffoldMessenger.of(context).snack(e.toString());
|
|
Navigator.of(context, rootNavigator: true).pop();
|
|
return;
|
|
}
|
|
|
|
//Route
|
|
Navigator.of(context, rootNavigator: true).pop();
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (context) => const ImporterStatusScreen()));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text("Spotify Importer v2".i18n)),
|
|
body: ListView(
|
|
children: [
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
|
|
child: Text(
|
|
'Logged in as: '.i18n + widget.spotify.me.displayName!,
|
|
maxLines: 1,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 18.0, fontWeight: FontWeight.bold)),
|
|
),
|
|
const FreezerDivider(),
|
|
Container(height: 4.0),
|
|
Text(
|
|
"Options".i18n,
|
|
textAlign: TextAlign.center,
|
|
style:
|
|
const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold),
|
|
),
|
|
const ImporterSettings(),
|
|
const FreezerDivider(),
|
|
Container(height: 4.0),
|
|
Text(
|
|
"Import playlists by URL".i18n,
|
|
textAlign: TextAlign.center,
|
|
style:
|
|
const TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold),
|
|
),
|
|
Padding(
|
|
padding:
|
|
const 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: const Icon(Icons.search),
|
|
onPressed: () => _loadUrl(),
|
|
)
|
|
],
|
|
)),
|
|
if (_urlLoading)
|
|
const 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: const EdgeInsets.symmetric(horizontal: 16.0),
|
|
child: ElevatedButton(
|
|
child: Text("Import".i18n),
|
|
onPressed: () {
|
|
_startImport(_urlPlaylist!.name,
|
|
_urlPlaylist!.description, _urlPlaylist!.id);
|
|
})),
|
|
|
|
// Playlists
|
|
const FreezerDivider(),
|
|
Container(height: 4.0),
|
|
Text("Playlists".i18n,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 18.0, fontWeight: FontWeight.bold)),
|
|
Container(height: 4.0),
|
|
if (_playlistsLoading)
|
|
const 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?.isEmpty ?? true)
|
|
? "http://cdn-images.deezer.com/images/cover//256x256-000000-80-0-0.jpg"
|
|
: p.images!.first.url!),
|
|
onTap: () {
|
|
_startImport(p.name, "", p.id);
|
|
},
|
|
);
|
|
})
|
|
],
|
|
));
|
|
}
|
|
}
|