freezer/lib/ui/library.dart
Pato05 87c9733f51
add build script for linux
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
2024-02-19 00:49:32 +01:00

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