Pato05
87c9733f51
fix audio service stop on android getTrack backend improvements get new track token when expired move shuffle button into LibraryPlaylists as FAB move favoriteButton next to track title move lyrics button on top of album art search: fix chips, and remove checkbox when selected
1313 lines
46 KiB
Dart
1313 lines
46 KiB
Dart
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:freezer/api/cache.dart';
|
|
import 'package:freezer/api/deezer.dart';
|
|
import 'package:freezer/api/definitions.dart';
|
|
import 'package:freezer/api/importer.dart';
|
|
import 'package:freezer/api/player/audio_handler.dart';
|
|
import 'package:freezer/icons.dart';
|
|
import 'package:freezer/settings.dart';
|
|
import 'package:freezer/ui/details_screens.dart';
|
|
import 'package:freezer/ui/elements.dart';
|
|
import 'package:freezer/ui/error.dart';
|
|
import 'package:freezer/ui/importer_screen.dart';
|
|
import 'package:freezer/ui/tiles.dart';
|
|
import 'package:freezer/translations.i18n.dart';
|
|
|
|
import 'menu.dart';
|
|
import '../api/download.dart';
|
|
|
|
class LibraryAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|
const LibraryAppBar({super.key});
|
|
|
|
@override
|
|
Size get preferredSize => AppBar().preferredSize;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AppBar(
|
|
title: Text('Library'.i18n),
|
|
actions: <Widget>[
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.file_download,
|
|
semanticLabel: "Download".i18n,
|
|
),
|
|
onPressed: () => Navigator.pushNamed(context, '/downloads'),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.settings,
|
|
semanticLabel: "Settings".i18n,
|
|
),
|
|
onPressed: () => Navigator.pushNamed(context, '/settings'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class LibraryScreen extends StatelessWidget {
|
|
const LibraryScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: const LibraryAppBar(),
|
|
body: ListView(
|
|
children: <Widget>[
|
|
const SizedBox(height: 4.0),
|
|
if (!downloadManager.running && downloadManager.queueSize! > 0)
|
|
ListTile(
|
|
title: Text('Downloads'.i18n),
|
|
leading:
|
|
const LeadingIcon(Icons.file_download, color: Colors.grey),
|
|
subtitle: Text(
|
|
'Downloading is currently stopped, click here to resume.'
|
|
.i18n),
|
|
onTap: () {
|
|
downloadManager.start();
|
|
Navigator.pushNamed(context, '/downloads');
|
|
},
|
|
),
|
|
// ListTile(
|
|
// title: Text('Shuffle'.i18n),
|
|
// leading: const LeadingIcon(Icons.shuffle, color: Color(0xffeca704)),
|
|
// onTap: () async {
|
|
// List<Track> tracks = (await deezerAPI.libraryShuffle())!;
|
|
// playerHelper.playFromTrackList(
|
|
// tracks,
|
|
// tracks[0].id,
|
|
// QueueSource(
|
|
// id: 'libraryshuffle',
|
|
// source: 'libraryshuffle',
|
|
// text: 'Library shuffle'.i18n));
|
|
// },
|
|
// ),
|
|
// const FreezerDivider(),
|
|
ListTile(
|
|
title: Text('Tracks'.i18n),
|
|
leading:
|
|
const LeadingIcon(Icons.audiotrack, color: Color(0xffbe3266)),
|
|
onTap: () => Navigator.pushNamed(context, '/library/tracks'),
|
|
),
|
|
ListTile(
|
|
title: Text('Albums'.i18n),
|
|
leading: const LeadingIcon(Icons.album, color: Color(0xff4b2e7e)),
|
|
onTap: () => Navigator.pushNamed(context, '/library/albums'),
|
|
),
|
|
ListTile(
|
|
title: Text('Artists'.i18n),
|
|
leading: const LeadingIcon(Icons.recent_actors,
|
|
color: Color(0xff384697)),
|
|
onTap: () => Navigator.pushNamed(context, '/library/artists'),
|
|
),
|
|
ListTile(
|
|
title: Text('Playlists'.i18n),
|
|
leading: const LeadingIcon(Icons.playlist_play,
|
|
color: Color(0xff0880b5)),
|
|
onTap: () => Navigator.pushNamed(context, '/library/playlists'),
|
|
),
|
|
const FreezerDivider(),
|
|
ListTile(
|
|
title: Text('History'.i18n),
|
|
leading: const LeadingIcon(Icons.history, color: Color(0xff009a85)),
|
|
onTap: () => Navigator.pushNamed(context, '/library/history'),
|
|
),
|
|
const FreezerDivider(),
|
|
ListTile(
|
|
title: Text('Import'.i18n),
|
|
leading: const LeadingIcon(Icons.import_export,
|
|
color: Color(0xff2ba766)),
|
|
subtitle: Text('Import playlists from Spotify'.i18n),
|
|
onTap: () {
|
|
//Show progress
|
|
if (importer.done || importer.busy) {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) => const ImporterStatusScreen());
|
|
return;
|
|
}
|
|
|
|
Navigator.of(context).pushNamed('/spotify-importer');
|
|
|
|
//Pick importer dialog (removed as ImporterV1 is broken)
|
|
// showDialog(
|
|
// context: context,
|
|
// builder: (context) => SimpleDialog(
|
|
// title: Text('Importer'.i18n),
|
|
// children: [
|
|
// ListTile(
|
|
// leading: const Icon(FreezerIcons.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).pushRoute(
|
|
// builder: (context) =>
|
|
// const SpotifyImporterV1());
|
|
// },
|
|
// ),
|
|
// ListTile(
|
|
// leading: const Icon(FreezerIcons.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).pushRoute(
|
|
// builder: (context) =>
|
|
// const SpotifyImporterV2());
|
|
// },
|
|
// )
|
|
// ],
|
|
// ));
|
|
},
|
|
),
|
|
if (DownloadManager.isSupported)
|
|
ExpansionTile(
|
|
title: Text('Statistics'.i18n),
|
|
leading:
|
|
const LeadingIcon(Icons.insert_chart, color: Colors.grey),
|
|
children: <Widget>[
|
|
FutureBuilder(
|
|
future: downloadManager.getStats(),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasError) return const ErrorScreen();
|
|
if (!snapshot.hasData) {
|
|
return const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 4.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[CircularProgressIndicator()],
|
|
),
|
|
);
|
|
}
|
|
List<String> data = snapshot.data!;
|
|
return Column(
|
|
children: <Widget>[
|
|
ListTile(
|
|
title: Text('Offline tracks'.i18n),
|
|
leading: const Icon(Icons.audiotrack),
|
|
trailing: Text(data[0]),
|
|
),
|
|
ListTile(
|
|
title: Text('Offline albums'.i18n),
|
|
leading: const Icon(Icons.album),
|
|
trailing: Text(data[1]),
|
|
),
|
|
ListTile(
|
|
title: Text('Offline playlists'.i18n),
|
|
leading: const Icon(Icons.playlist_add),
|
|
trailing: Text(data[2]),
|
|
),
|
|
ListTile(
|
|
title: Text('Offline size'.i18n),
|
|
leading: const Icon(Icons.sd_card),
|
|
trailing: Text(data[3]),
|
|
),
|
|
ListTile(
|
|
title: Text('Free space'.i18n),
|
|
leading: const Icon(Icons.disc_full),
|
|
trailing: Text(data[4]),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
)
|
|
],
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class LibraryTracks extends StatefulWidget {
|
|
const LibraryTracks({super.key});
|
|
|
|
@override
|
|
State<LibraryTracks> createState() => _LibraryTracksState();
|
|
}
|
|
|
|
class _LibraryTracksState extends State<LibraryTracks> {
|
|
bool _loading = false;
|
|
bool _loadingTracks = false;
|
|
final ScrollController _scrollController = ScrollController();
|
|
List<Track> tracks = [];
|
|
List<Track> allTracks = [];
|
|
int? trackCount;
|
|
Sorting? _sort = Sorting(sourceType: SortSourceTypes.TRACKS);
|
|
|
|
Playlist get _playlist => Playlist(id: deezerAPI.favoritesPlaylistId!);
|
|
|
|
List<Track> get _sorted {
|
|
List<Track> tcopy = List.from(tracks);
|
|
tcopy.sort((a, b) => a.addedDate!.compareTo(b.addedDate!));
|
|
switch (_sort!.type) {
|
|
case SortType.ALPHABETIC:
|
|
tcopy.sort((a, b) => a.title!.compareTo(b.title!));
|
|
break;
|
|
case SortType.ARTIST:
|
|
tcopy.sort((a, b) => a.artists![0].name!
|
|
.toLowerCase()
|
|
.compareTo(b.artists![0].name!.toLowerCase()));
|
|
break;
|
|
case SortType.DEFAULT:
|
|
default:
|
|
break;
|
|
}
|
|
//Reverse
|
|
if (_sort!.reverse!) return tcopy.reversed.toList();
|
|
return tcopy;
|
|
}
|
|
|
|
Future _reverse() async {
|
|
setState(() => _sort!.reverse = !_sort!.reverse!);
|
|
//Save sorting in cache
|
|
int? index = Sorting.index(SortSourceTypes.TRACKS);
|
|
if (index != null) {
|
|
cache.sorts[index] = _sort;
|
|
} else {
|
|
cache.sorts.add(_sort);
|
|
}
|
|
await cache.save();
|
|
|
|
//Preload for sorting
|
|
if (tracks.length < (trackCount ?? 0)) _loadFull();
|
|
}
|
|
|
|
Future _load() async {
|
|
//Already loaded
|
|
if (trackCount != null && tracks.length >= trackCount!) {
|
|
//Update tracks cache if fully loaded
|
|
if (cache.libraryTracks.length != trackCount) {
|
|
cache.libraryTracks = tracks.map((t) => t.id).toList();
|
|
await cache.save();
|
|
}
|
|
return;
|
|
}
|
|
|
|
ConnectivityResult connectivity = await Connectivity().checkConnectivity();
|
|
if (connectivity != ConnectivityResult.none) {
|
|
setState(() => _loading = true);
|
|
int pos = tracks.length;
|
|
|
|
if (trackCount == null || tracks.isEmpty) {
|
|
//Load tracks as a playlist
|
|
Playlist? favPlaylist;
|
|
try {
|
|
favPlaylist = await deezerAPI.playlist(deezerAPI.favoritesPlaylistId);
|
|
} catch (e) {}
|
|
//Error loading
|
|
if (favPlaylist == null) {
|
|
setState(() => _loading = false);
|
|
return;
|
|
}
|
|
//Update
|
|
if (mounted) {
|
|
setState(() {
|
|
trackCount = favPlaylist!.trackCount;
|
|
if (tracks.isEmpty && favPlaylist.tracks != null) {
|
|
tracks = favPlaylist.tracks!;
|
|
}
|
|
_makeFavorite();
|
|
_loading = false;
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
//Load another page of tracks from deezer
|
|
if (_loadingTracks) return;
|
|
_loadingTracks = true;
|
|
|
|
List<Track>? t;
|
|
try {
|
|
t = await deezerAPI.playlistTracksPage(
|
|
deezerAPI.favoritesPlaylistId, pos);
|
|
} catch (e) {}
|
|
//On error load offline
|
|
if (t == null) {
|
|
await _loadOffline();
|
|
return;
|
|
}
|
|
setState(() {
|
|
tracks.addAll(t!);
|
|
_makeFavorite();
|
|
_loading = false;
|
|
_loadingTracks = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
//Load all tracks
|
|
Future _loadFull() async {
|
|
if (tracks.isEmpty || tracks.length < (trackCount ?? 0)) {
|
|
Playlist? p;
|
|
try {
|
|
p = await deezerAPI.fullPlaylist(deezerAPI.favoritesPlaylistId);
|
|
} catch (e) {}
|
|
if (p != null) {
|
|
setState(() {
|
|
tracks.addAll(p!.tracks!);
|
|
trackCount = p.trackCount;
|
|
_sort = _sort;
|
|
});
|
|
|
|
if (cache.libraryTracks.length != trackCount) {
|
|
cache.libraryTracks = tracks.map((t) => t.id).toList();
|
|
await cache.save();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _loadOffline() async {
|
|
if (deezerAPI.favoritesPlaylistId == null) return;
|
|
Playlist? p =
|
|
await downloadManager.getPlaylistFromId(deezerAPI.favoritesPlaylistId!);
|
|
if (p != null) {
|
|
setState(() {
|
|
tracks.addAll(p.tracks!);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future _loadAllOffline() async {
|
|
List<Track> tracks = await downloadManager.allOfflineTracks();
|
|
setState(() {
|
|
allTracks = tracks;
|
|
});
|
|
}
|
|
|
|
//Update tracks with favorite true
|
|
void _makeFavorite() {
|
|
for (int i = 0; i < tracks.length; i++) {
|
|
tracks[i].favorite = true;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
_scrollController.addListener(() {
|
|
//Load more tracks on scroll
|
|
double off = _scrollController.position.maxScrollExtent * 0.90;
|
|
if (_scrollController.position.pixels > off) _load();
|
|
});
|
|
|
|
_load();
|
|
//Load all offline tracks
|
|
_loadAllOffline();
|
|
|
|
//Load sorting
|
|
int? index = Sorting.index(SortSourceTypes.TRACKS);
|
|
if (index != null) setState(() => _sort = cache.sorts[index]);
|
|
|
|
if (_sort!.type != SortType.DEFAULT || _sort!.reverse!) _loadFull();
|
|
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Tracks'.i18n),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(
|
|
_sort!.reverse!
|
|
? FreezerIcons.sort_alpha_up
|
|
: FreezerIcons.sort_alpha_down,
|
|
semanticLabel: _sort!.reverse!
|
|
? "Sort descending".i18n
|
|
: "Sort ascending".i18n,
|
|
),
|
|
onPressed: () async {
|
|
await _reverse();
|
|
}),
|
|
PopupMenuButton(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
onSelected: (SortType s) async {
|
|
//Preload for sorting
|
|
if (tracks.length < (trackCount ?? 0)) await _loadFull();
|
|
|
|
setState(() => _sort!.type = s);
|
|
//Save sorting in cache
|
|
int? index = Sorting.index(SortSourceTypes.TRACKS);
|
|
if (index != null) {
|
|
cache.sorts[index] = _sort;
|
|
} else {
|
|
cache.sorts.add(_sort);
|
|
}
|
|
await cache.save();
|
|
},
|
|
itemBuilder: (context) => <PopupMenuEntry<SortType>>[
|
|
PopupMenuItem(
|
|
value: SortType.DEFAULT,
|
|
child: Text('Default'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.ALPHABETIC,
|
|
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.ARTIST,
|
|
child: Text('Artist'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
],
|
|
child: Icon(
|
|
Icons.sort,
|
|
size: 32.0,
|
|
semanticLabel: "Sort".i18n,
|
|
),
|
|
),
|
|
const SizedBox(width: 8.0),
|
|
],
|
|
),
|
|
body: _loading && allTracks.isEmpty
|
|
? const Center(child: CircularProgressIndicator())
|
|
: Scrollbar(
|
|
interactive: true,
|
|
controller: _scrollController,
|
|
thickness: 8.0,
|
|
child: ListView(
|
|
controller: _scrollController,
|
|
children: <Widget>[
|
|
if (!_loading)
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: <Widget>[
|
|
MakePlaylistOffline(_playlist),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.file_download),
|
|
label: Text('Download'.i18n),
|
|
onPressed: () async {
|
|
if (await downloadManager.addOfflinePlaylist(
|
|
_playlist,
|
|
private: false,
|
|
context: context) !=
|
|
false) {
|
|
MenuSheet(context).showDownloadStartedToast();
|
|
}
|
|
},
|
|
)
|
|
]),
|
|
const FreezerDivider(),
|
|
//Loved tracks
|
|
if (_loading)
|
|
const Center(child: CircularProgressIndicator()),
|
|
...List.generate(tracks.length, (i) {
|
|
Track? t = (tracks.length == (trackCount ?? 0))
|
|
? _sorted[i]
|
|
: tracks[i];
|
|
return TrackTile.fromTrack(
|
|
t,
|
|
onTap: () {
|
|
playerHelper.playFromTrackList(
|
|
(tracks.length == (trackCount ?? 0))
|
|
? _sorted
|
|
: tracks,
|
|
t.id,
|
|
QueueSource(
|
|
id: deezerAPI.favoritesPlaylistId,
|
|
text: 'Favorites'.i18n,
|
|
source: 'playlist'));
|
|
},
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultTrackMenu(t, details: details, onRemove: () {
|
|
setState(() {
|
|
tracks.removeWhere((track) => t.id == track.id);
|
|
});
|
|
});
|
|
},
|
|
);
|
|
}),
|
|
const FreezerDivider(),
|
|
Text(
|
|
'All offline tracks'.i18n,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 24, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8.0),
|
|
for (final track in allTracks)
|
|
TrackTile.fromTrack(track, onTap: () {
|
|
playerHelper.playFromTrackList(
|
|
allTracks,
|
|
track.id,
|
|
QueueSource(
|
|
id: 'allTracks',
|
|
text: 'All offline tracks'.i18n,
|
|
source: 'offline'));
|
|
}, onSecondary: (details) {
|
|
MenuSheet(context)
|
|
.defaultTrackMenu(track, details: details);
|
|
}),
|
|
],
|
|
)));
|
|
}
|
|
}
|
|
|
|
class LibraryAlbums extends StatefulWidget {
|
|
const LibraryAlbums({super.key});
|
|
|
|
@override
|
|
State<LibraryAlbums> createState() => _LibraryAlbumsState();
|
|
}
|
|
|
|
class _LibraryAlbumsState extends State<LibraryAlbums> {
|
|
List<Album>? _albums;
|
|
Sorting? _sort = Sorting(sourceType: SortSourceTypes.ALBUMS);
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
List<Album> get _sorted {
|
|
List<Album> albums = List.from(_albums!);
|
|
albums.sort((a, b) => a.favoriteDate!.compareTo(b.favoriteDate!));
|
|
switch (_sort!.type) {
|
|
case SortType.DEFAULT:
|
|
break;
|
|
case SortType.ALPHABETIC:
|
|
albums.sort(
|
|
(a, b) => a.title!.toLowerCase().compareTo(b.title!.toLowerCase()));
|
|
break;
|
|
case SortType.ARTIST:
|
|
albums.sort((a, b) => a.artists![0].name!
|
|
.toLowerCase()
|
|
.compareTo(b.artists![0].name!.toLowerCase()));
|
|
break;
|
|
case SortType.RELEASE_DATE:
|
|
albums.sort((a, b) => DateTime.parse(a.releaseDate!)
|
|
.compareTo(DateTime.parse(b.releaseDate!)));
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
//Reverse
|
|
if (_sort!.reverse!) return albums.reversed.toList();
|
|
return albums;
|
|
}
|
|
|
|
Future _load() async {
|
|
if (settings.offlineMode) return;
|
|
try {
|
|
List<Album> albums = await deezerAPI.getAlbums();
|
|
setState(() => _albums = albums);
|
|
} catch (e) {}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
_load();
|
|
//Load sorting
|
|
int? index = Sorting.index(SortSourceTypes.ALBUMS);
|
|
if (index != null) _sort = cache.sorts[index];
|
|
|
|
super.initState();
|
|
}
|
|
|
|
Future _reverse() async {
|
|
setState(() => _sort!.reverse = !_sort!.reverse!);
|
|
//Save sorting in cache
|
|
int? index = Sorting.index(SortSourceTypes.ALBUMS);
|
|
if (index != null) {
|
|
cache.sorts[index] = _sort;
|
|
} else {
|
|
cache.sorts.add(_sort);
|
|
}
|
|
await cache.save();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Albums'.i18n),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(
|
|
_sort!.reverse!
|
|
? FreezerIcons.sort_alpha_up
|
|
: FreezerIcons.sort_alpha_down,
|
|
semanticLabel: _sort!.reverse!
|
|
? "Sort descending".i18n
|
|
: "Sort ascending".i18n,
|
|
),
|
|
onPressed: () => _reverse(),
|
|
),
|
|
PopupMenuButton(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
child: const Icon(Icons.sort, size: 32.0),
|
|
onSelected: (SortType s) async {
|
|
setState(() => _sort!.type = s);
|
|
//Save to cache
|
|
int? index = Sorting.index(SortSourceTypes.ALBUMS);
|
|
if (index == null) {
|
|
cache.sorts.add(_sort);
|
|
} else {
|
|
cache.sorts[index] = _sort;
|
|
}
|
|
await cache.save();
|
|
},
|
|
itemBuilder: (context) => <PopupMenuEntry<SortType>>[
|
|
PopupMenuItem(
|
|
value: SortType.DEFAULT,
|
|
child: Text('Default'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.ALPHABETIC,
|
|
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.ARTIST,
|
|
child: Text('Artist'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.RELEASE_DATE,
|
|
child: Text('Release date'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(width: 8.0),
|
|
],
|
|
),
|
|
body: !settings.offlineMode && _albums == null
|
|
? const Center(child: CircularProgressIndicator())
|
|
: Scrollbar(
|
|
interactive: true,
|
|
controller: _scrollController,
|
|
thickness: 8.0,
|
|
child: ListView(
|
|
controller: _scrollController,
|
|
children: <Widget>[
|
|
const SizedBox(height: 8.0),
|
|
if (_albums != null)
|
|
...List.generate(_albums!.length, (int i) {
|
|
Album a = _sorted[i];
|
|
return AlbumTile(
|
|
a,
|
|
onTap: () {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) => AlbumDetails(a));
|
|
},
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultAlbumMenu(
|
|
a,
|
|
details: details,
|
|
onRemove: () {
|
|
setState(() => _albums!.remove(a));
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
FutureBuilder(
|
|
future: downloadManager.getOfflineAlbums(),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasError ||
|
|
!snapshot.hasData ||
|
|
(snapshot.data!).isEmpty) {
|
|
return const SizedBox(
|
|
height: 0,
|
|
width: 0,
|
|
);
|
|
}
|
|
|
|
List<Album> albums = snapshot.data as List<Album>;
|
|
return Column(
|
|
children: <Widget>[
|
|
const FreezerDivider(),
|
|
Text(
|
|
'Offline albums'.i18n,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold, fontSize: 24.0),
|
|
),
|
|
...List.generate(albums.length, (i) {
|
|
Album a = albums[i];
|
|
return AlbumTile(
|
|
a,
|
|
onTap: () {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) => AlbumDetails(a));
|
|
},
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultAlbumMenu(a, details: details,
|
|
onRemove: () {
|
|
setState(() {
|
|
albums.remove(a);
|
|
_albums!.remove(a);
|
|
});
|
|
});
|
|
},
|
|
);
|
|
})
|
|
],
|
|
);
|
|
},
|
|
)
|
|
],
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
class LibraryArtists extends StatefulWidget {
|
|
const LibraryArtists({super.key});
|
|
|
|
@override
|
|
State<LibraryArtists> createState() => _LibraryArtistsState();
|
|
}
|
|
|
|
class _LibraryArtistsState extends State<LibraryArtists> {
|
|
late List<Artist> _artists;
|
|
Sorting? _sort = Sorting(sourceType: SortSourceTypes.ARTISTS);
|
|
bool _loading = true;
|
|
bool _error = false;
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
List<Artist> get _sorted {
|
|
List<Artist> artists = List.from(_artists);
|
|
artists.sort((a, b) => a.favoriteDate!.compareTo(b.favoriteDate!));
|
|
switch (_sort!.type) {
|
|
case SortType.DEFAULT:
|
|
break;
|
|
case SortType.POPULARITY:
|
|
artists.sort((a, b) => b.fans! - a.fans!);
|
|
break;
|
|
case SortType.ALPHABETIC:
|
|
artists.sort(
|
|
(a, b) => a.name!.toLowerCase().compareTo(b.name!.toLowerCase()));
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
//Reverse
|
|
if (_sort!.reverse!) return artists.reversed.toList();
|
|
return artists;
|
|
}
|
|
|
|
//Load data
|
|
Future _load() async {
|
|
setState(() => _loading = true);
|
|
//Fetch
|
|
List<Artist>? data;
|
|
try {
|
|
data = await deezerAPI.getArtists();
|
|
} catch (e) {}
|
|
//Update UI
|
|
setState(() {
|
|
if (data != null) {
|
|
_artists = data;
|
|
} else {
|
|
_error = true;
|
|
}
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
Future _reverse() async {
|
|
setState(() => _sort!.reverse = !_sort!.reverse!);
|
|
//Save sorting in cache
|
|
int? index = Sorting.index(SortSourceTypes.ARTISTS);
|
|
if (index != null) {
|
|
cache.sorts[index] = _sort;
|
|
} else {
|
|
cache.sorts.add(_sort);
|
|
}
|
|
await cache.save();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
//Restore sort
|
|
int? index = Sorting.index(SortSourceTypes.ARTISTS);
|
|
if (index != null) _sort = cache.sorts[index];
|
|
|
|
_load();
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Artists'.i18n),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(
|
|
_sort!.reverse!
|
|
? FreezerIcons.sort_alpha_up
|
|
: FreezerIcons.sort_alpha_down,
|
|
semanticLabel: _sort!.reverse!
|
|
? "Sort descending".i18n
|
|
: "Sort ascending".i18n,
|
|
),
|
|
onPressed: () => _reverse(),
|
|
),
|
|
PopupMenuButton(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
onSelected: (SortType s) async {
|
|
setState(() => _sort!.type = s);
|
|
//Save
|
|
int? index = Sorting.index(SortSourceTypes.ARTISTS);
|
|
if (index == null) {
|
|
cache.sorts.add(_sort);
|
|
} else {
|
|
cache.sorts[index] = _sort;
|
|
}
|
|
await cache.save();
|
|
},
|
|
itemBuilder: (context) => <PopupMenuEntry<SortType>>[
|
|
PopupMenuItem(
|
|
value: SortType.DEFAULT,
|
|
child: Text('Default'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.ALPHABETIC,
|
|
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.POPULARITY,
|
|
child: Text('Popularity'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
],
|
|
child: const Icon(Icons.sort, size: 32.0),
|
|
),
|
|
const SizedBox(width: 8.0),
|
|
],
|
|
),
|
|
body: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _error
|
|
? const Center(child: ErrorScreen())
|
|
: Scrollbar(
|
|
interactive: true,
|
|
controller: _scrollController,
|
|
thickness: 8.0,
|
|
child: ListView(
|
|
controller: _scrollController,
|
|
children: <Widget>[
|
|
for (final artist in _sorted)
|
|
ArtistHorizontalTile(
|
|
artist,
|
|
onTap: () {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) => ArtistDetails(artist));
|
|
},
|
|
onHold: () {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultArtistMenu(artist, onRemove: () {
|
|
setState(() {
|
|
_artists.remove(artist);
|
|
});
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
));
|
|
}
|
|
}
|
|
|
|
class LibraryPlaylists extends StatefulWidget {
|
|
const LibraryPlaylists({super.key});
|
|
|
|
@override
|
|
State<LibraryPlaylists> createState() => _LibraryPlaylistsState();
|
|
}
|
|
|
|
class _LibraryPlaylistsState extends State<LibraryPlaylists> {
|
|
List<Playlist>? _playlists;
|
|
Sorting? _sort = Sorting(sourceType: SortSourceTypes.PLAYLISTS);
|
|
final ScrollController _scrollController = ScrollController();
|
|
String _filter = '';
|
|
|
|
List<Playlist> get _sorted {
|
|
List<Playlist> playlists = List.from(_playlists!
|
|
.where((p) => p.title!.toLowerCase().contains(_filter.toLowerCase())));
|
|
switch (_sort!.type) {
|
|
case SortType.DEFAULT:
|
|
break;
|
|
case SortType.USER:
|
|
playlists.sort((a, b) => (a.user!.name ?? deezerAPI.userName)!
|
|
.toLowerCase()
|
|
.compareTo((b.user!.name ?? deezerAPI.userName)!.toLowerCase()));
|
|
break;
|
|
case SortType.TRACK_COUNT:
|
|
playlists.sort((a, b) => b.trackCount! - a.trackCount!);
|
|
break;
|
|
case SortType.ALPHABETIC:
|
|
playlists.sort(
|
|
(a, b) => a.title!.toLowerCase().compareTo(b.title!.toLowerCase()));
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
if (_sort!.reverse!) return playlists.reversed.toList();
|
|
return playlists;
|
|
}
|
|
|
|
Future _load() async {
|
|
if (cache.favoritePlaylists != null) {
|
|
setState(() => _playlists =
|
|
cache.favoritePlaylists!.value.values.toList(growable: false));
|
|
|
|
if (DateTime.now().difference(cache.favoritePlaylists!.updatedAt) <
|
|
const Duration(hours: 1)) return;
|
|
}
|
|
if (!settings.offlineMode) {
|
|
try {
|
|
final List<Playlist> playlists = await deezerAPI.getPlaylists();
|
|
setState(() => _playlists = playlists);
|
|
if (cache.favoritePlaylists == null) {
|
|
cache.favoritePlaylists =
|
|
CacheEntry({for (final p in playlists) p.id: p});
|
|
} else {
|
|
// update non-destructively
|
|
final oldEntry = cache.favoritePlaylists!.value;
|
|
final newEntry = <String, Playlist>{};
|
|
for (final playlist in playlists) {
|
|
if (oldEntry.containsKey(playlist.id)) {
|
|
newEntry[playlist.id] = oldEntry[playlist.id]!;
|
|
} else {
|
|
newEntry[playlist.id] = playlist;
|
|
}
|
|
}
|
|
}
|
|
await cache.save();
|
|
return;
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
Future _reverse() async {
|
|
setState(() => _sort!.reverse = !_sort!.reverse!);
|
|
//Save sorting in cache
|
|
int? index = Sorting.index(SortSourceTypes.PLAYLISTS);
|
|
if (index != null) {
|
|
cache.sorts[index] = _sort;
|
|
} else {
|
|
cache.sorts.add(_sort);
|
|
}
|
|
await cache.save();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
//Restore sort
|
|
int? index = Sorting.index(SortSourceTypes.PLAYLISTS);
|
|
if (index != null) _sort = cache.sorts[index];
|
|
|
|
_load();
|
|
super.initState();
|
|
}
|
|
|
|
Playlist get favoritesPlaylist => Playlist(
|
|
id: deezerAPI.favoritesPlaylistId!,
|
|
title: 'Favorites'.i18n,
|
|
user: User(name: deezerAPI.userName),
|
|
image: UrlImageDetails.single('assets/favorites_thumb.jpg'),
|
|
tracks: [],
|
|
trackCount: 1,
|
|
duration: const Duration(seconds: 0));
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('Playlists'.i18n),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(
|
|
_sort!.reverse!
|
|
? FreezerIcons.sort_alpha_up
|
|
: FreezerIcons.sort_alpha_down,
|
|
semanticLabel: _sort!.reverse!
|
|
? "Sort descending".i18n
|
|
: "Sort ascending".i18n,
|
|
),
|
|
onPressed: () => _reverse(),
|
|
),
|
|
PopupMenuButton(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
onSelected: (SortType s) async {
|
|
setState(() => _sort!.type = s);
|
|
//Save to cache
|
|
int? index = Sorting.index(SortSourceTypes.PLAYLISTS);
|
|
if (index == null) {
|
|
cache.sorts.add(_sort);
|
|
} else {
|
|
cache.sorts[index] = _sort;
|
|
}
|
|
|
|
await cache.save();
|
|
},
|
|
itemBuilder: (context) => <PopupMenuEntry<SortType>>[
|
|
PopupMenuItem(
|
|
value: SortType.DEFAULT,
|
|
child: Text('Default'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.USER,
|
|
child: Text('User'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.TRACK_COUNT,
|
|
child: Text('Track count'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
PopupMenuItem(
|
|
value: SortType.ALPHABETIC,
|
|
child: Text('Alphabetic'.i18n, style: popupMenuTextStyle()),
|
|
),
|
|
],
|
|
child: const Icon(Icons.sort, size: 32.0),
|
|
),
|
|
Container(width: 8.0),
|
|
],
|
|
),
|
|
body: Scrollbar(
|
|
interactive: true,
|
|
controller: _scrollController,
|
|
thickness: 8.0,
|
|
child: ListView(
|
|
controller: _scrollController,
|
|
children: <Widget>[
|
|
//Search
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: TextField(
|
|
onChanged: (String s) => setState(() => _filter = s),
|
|
decoration: InputDecoration(
|
|
labelText: 'Search'.i18n,
|
|
prefixIcon: Icon(Icons.search),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(30.0)),
|
|
)),
|
|
),
|
|
ListTile(
|
|
title: Text('Create new playlist'.i18n),
|
|
leading: const LeadingIcon(Icons.playlist_add,
|
|
color: Color(0xff009a85)),
|
|
onTap: () async {
|
|
if (settings.offlineMode) {
|
|
ScaffoldMessenger.of(context)
|
|
.snack('Cannot create playlists in offline mode'.i18n);
|
|
return;
|
|
}
|
|
MenuSheet m = MenuSheet(context);
|
|
await m.createPlaylist();
|
|
await _load();
|
|
},
|
|
),
|
|
const FreezerDivider(),
|
|
|
|
if (!settings.offlineMode && _playlists == null)
|
|
const Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: <Widget>[
|
|
CircularProgressIndicator(),
|
|
],
|
|
),
|
|
|
|
//Favorites playlist
|
|
PlaylistTile(
|
|
favoritesPlaylist,
|
|
onTap: () async {
|
|
Navigator.of(context).pushRoute(
|
|
builder: (context) => PlaylistDetails(favoritesPlaylist));
|
|
},
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
favoritesPlaylist.library = true;
|
|
m.defaultPlaylistMenu(favoritesPlaylist, details: details);
|
|
},
|
|
),
|
|
|
|
if (_playlists != null)
|
|
...List.generate(_sorted.length, (int i) {
|
|
Playlist p = _sorted[i];
|
|
return PlaylistTile(
|
|
p,
|
|
onTap: () => Navigator.of(context)
|
|
.pushRoute(builder: (context) => PlaylistDetails(p)),
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultPlaylistMenu(p, details: details, onRemove: () {
|
|
setState(() => _playlists!.remove(p));
|
|
}, onUpdate: () {
|
|
_load();
|
|
});
|
|
},
|
|
);
|
|
}),
|
|
|
|
FutureBuilder(
|
|
future: downloadManager.getOfflinePlaylists(),
|
|
builder: (context, AsyncSnapshot<List<Playlist>> snapshot) {
|
|
if (snapshot.hasError ||
|
|
!snapshot.hasData ||
|
|
snapshot.data!.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
List<Playlist> playlists = snapshot.data!;
|
|
return Column(
|
|
children: <Widget>[
|
|
const FreezerDivider(),
|
|
Text(
|
|
'Offline playlists'.i18n,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 24.0, fontWeight: FontWeight.bold),
|
|
),
|
|
...List.generate(playlists.length, (i) {
|
|
Playlist p = playlists[i];
|
|
return PlaylistTile(
|
|
p,
|
|
onTap: () => Navigator.of(context).pushRoute(
|
|
builder: (context) => PlaylistDetails(p)),
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultPlaylistMenu(p, details: details,
|
|
onRemove: () {
|
|
setState(() {
|
|
playlists.remove(p);
|
|
_playlists!.remove(p);
|
|
});
|
|
});
|
|
},
|
|
);
|
|
})
|
|
],
|
|
);
|
|
},
|
|
)
|
|
],
|
|
),
|
|
),
|
|
floatingActionButton: AwaitingFloatingActionButton(
|
|
onPressed: () async {
|
|
await Future.delayed(Durations.extralong4);
|
|
List<Track> tracks = (await deezerAPI.libraryShuffle())!;
|
|
playerHelper.playFromTrackList(
|
|
tracks,
|
|
tracks[0].id,
|
|
QueueSource(
|
|
id: 'libraryshuffle',
|
|
source: 'libraryshuffle',
|
|
text: 'Library shuffle'.i18n));
|
|
},
|
|
child: const Icon(Icons.shuffle)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class AwaitingFloatingActionButton extends StatefulWidget {
|
|
final Widget child;
|
|
final Future<void> Function() onPressed;
|
|
final double size;
|
|
const AwaitingFloatingActionButton(
|
|
{required this.onPressed,
|
|
required this.child,
|
|
this.size = 24.0,
|
|
super.key});
|
|
|
|
@override
|
|
State<AwaitingFloatingActionButton> createState() =>
|
|
_AwaitingFloatingActionButtonState();
|
|
}
|
|
|
|
class _AwaitingFloatingActionButtonState
|
|
extends State<AwaitingFloatingActionButton> {
|
|
bool _loading = false;
|
|
void _onPressed() async {
|
|
setState(() {
|
|
_loading = true;
|
|
});
|
|
|
|
await widget.onPressed();
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_loading = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FloatingActionButton(
|
|
onPressed: _onPressed,
|
|
child: SizedBox.square(
|
|
dimension: widget.size,
|
|
child: _loading
|
|
? const CircularProgressIndicator(
|
|
color: Colors.white, strokeWidth: 2.5)
|
|
: widget.child),
|
|
);
|
|
}
|
|
}
|
|
|
|
class HistoryScreen extends StatefulWidget {
|
|
const HistoryScreen({super.key});
|
|
|
|
@override
|
|
State<HistoryScreen> createState() => _HistoryScreenState();
|
|
}
|
|
|
|
class _HistoryScreenState extends State<HistoryScreen> {
|
|
final ScrollController _scrollController = ScrollController();
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text('History'.i18n),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.delete_sweep,
|
|
semanticLabel: "Clear all".i18n,
|
|
),
|
|
onPressed: () {
|
|
setState(() => cache.history = []);
|
|
cache.save();
|
|
},
|
|
)
|
|
],
|
|
),
|
|
body: Scrollbar(
|
|
interactive: true,
|
|
controller: _scrollController,
|
|
thickness: 8.0,
|
|
child: ListView.builder(
|
|
controller: _scrollController,
|
|
itemCount: cache.history.length,
|
|
itemBuilder: (BuildContext context, int i) {
|
|
Track t = cache.history[cache.history.length - i - 1];
|
|
return TrackTile.fromTrack(
|
|
t,
|
|
onTap: () {
|
|
playerHelper.playFromTrackList(
|
|
cache.history.reversed.toList(),
|
|
t.id,
|
|
QueueSource(
|
|
id: null, text: 'History'.i18n, source: 'history'));
|
|
},
|
|
onSecondary: (details) {
|
|
MenuSheet m = MenuSheet(context);
|
|
m.defaultTrackMenu(t, details: details);
|
|
},
|
|
checkTrackOffline: false,
|
|
);
|
|
},
|
|
)),
|
|
);
|
|
}
|
|
}
|