freezer/lib/ui/importer_screen.dart
Pato05 2862c9ec05
remove browser login for desktop
restore translations functionality
make scrollViews handle mouse pointers like touch, so that pull to refresh functionality is available
exit app if opening cache or settings fails (another instance running)
remove draggable_scrollbar and use builtin widget instead
fix email login
better way to manage lyrics (less updates and lookups in the lyrics List)
fix player_screen on mobile (too big -> just average :))
right click: use TapUp events instead
desktop: show context menu on triple dots button also
avoid showing connection error if the homepage is cached and available offline
i'm probably forgetting something idk
2023-10-25 00:32:28 +02:00

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) => WillPopScope(
onWillPop: () => Future.value(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);
},
);
})
],
));
}
}