diff --git a/lib/components/Album/AlbumView.dart b/lib/components/Album/AlbumView.dart index d545e890..71d551f7 100644 --- a/lib/components/Album/AlbumView.dart +++ b/lib/components/Album/AlbumView.dart @@ -6,7 +6,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/TrackCollectionView.dart'; +import 'package:spotube/components/Shared/TracksTableView.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; +import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/provider/Auth.dart'; @@ -18,14 +20,20 @@ class AlbumView extends HookConsumerWidget { final AlbumSimple album; const AlbumView(this.album, {Key? key}) : super(key: key); - Future playPlaylist(Playback playback, List tracks, - {Track? currentTrack}) async { - currentTrack ??= tracks.first; + Future playPlaylist( + Playback playback, + List tracks, + WidgetRef ref, { + Track? currentTrack, + }) async { + final sortBy = ref.read(trackCollectionSortState(album.id!)); + final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy); + currentTrack ??= sortedTracks.first; final isPlaylistPlaying = playback.playlist?.id == album.id; if (!isPlaylistPlaying) { await playback.playPlaylist( CurrentPlaylist( - tracks: tracks, + tracks: sortedTracks, id: album.id!, name: album.name!, thumbnail: TypeConversionUtils.image_X_UrlString( @@ -33,7 +41,7 @@ class AlbumView extends HookConsumerWidget { placeholder: ImagePlaceholder.collection, ), ), - tracks.indexWhere((s) => s.id == currentTrack?.id), + sortedTracks.indexWhere((s) => s.id == currentTrack?.id), ); } else if (isPlaylistPlaying && currentTrack.id != null && @@ -82,6 +90,7 @@ class AlbumView extends HookConsumerWidget { .map((track) => TypeConversionUtils.simpleTrack_X_Track(track, album)) .toList(), + ref, ); } else if (isAlbumPlaying && track != null) { playPlaylist( @@ -91,6 +100,7 @@ class AlbumView extends HookConsumerWidget { TypeConversionUtils.simpleTrack_X_Track(track, album)) .toList(), currentTrack: track, + ref, ); } else { playback.stop(); diff --git a/lib/components/Library/UserLocalTracks.dart b/lib/components/Library/UserLocalTracks.dart index a8cd9905..71cfa155 100644 --- a/lib/components/Library/UserLocalTracks.dart +++ b/lib/components/Library/UserLocalTracks.dart @@ -1,7 +1,9 @@ import 'dart:io'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:mime/mime.dart'; @@ -10,6 +12,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart'; +import 'package:spotube/components/Shared/SortTracksDropdown.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/hooks/useAsyncEffect.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; @@ -18,6 +21,7 @@ import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; const supportedAudioTypes = [ @@ -37,6 +41,15 @@ const imgMimeToExt = { "image/gif": ".gif", }; +enum SortBy { + none, + ascending, + descending, + artist, + album, + dateAdded, +} + final localTracksProvider = FutureProvider>((ref) async { try { if (kIsWeb) return []; @@ -132,6 +145,7 @@ class UserLocalTracks extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final sortBy = useState(SortBy.none); final playback = ref.watch(playbackProvider); final isPlaylistPlaying = playback.playlist?.id == "local"; final trackSnapshot = ref.watch(localTracksProvider); @@ -176,6 +190,13 @@ class UserLocalTracks extends HookConsumerWidget { : null, ), const Spacer(), + SortTracksDropdown( + value: sortBy.value, + onChanged: (value) { + if (value != null) sortBy.value = value; + }, + ), + const SizedBox(width: 10), ElevatedButton( child: const Icon(Icons.refresh_rounded), onPressed: () { @@ -187,11 +208,15 @@ class UserLocalTracks extends HookConsumerWidget { ), trackSnapshot.when( data: (tracks) { + final sortedTracks = useMemoized(() { + return ServiceUtils.sortTracks(tracks, sortBy.value); + }, [sortBy.value, tracks]); + return Expanded( child: ListView.builder( - itemCount: tracks.length, + itemCount: sortedTracks.length, itemBuilder: (context, index) { - final track = tracks[index]; + final track = sortedTracks[index]; return TrackTile( playback, duration: @@ -204,7 +229,7 @@ class UserLocalTracks extends HookConsumerWidget { onTrackPlayButtonPressed: (currentTrack) { return playLocalTracks( playback, - tracks, + sortedTracks, currentTrack: track, ); }, diff --git a/lib/components/Playlist/PlaylistView.dart b/lib/components/Playlist/PlaylistView.dart index 07dd593f..f66223e0 100644 --- a/lib/components/Playlist/PlaylistView.dart +++ b/lib/components/Playlist/PlaylistView.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/TrackCollectionView.dart'; +import 'package:spotube/components/Shared/TracksTableView.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/usePaletteColor.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; @@ -16,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; +import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistView extends HookConsumerWidget { @@ -23,15 +25,21 @@ class PlaylistView extends HookConsumerWidget { final PlaylistSimple playlist; PlaylistView(this.playlist, {Key? key}) : super(key: key); - playPlaylist(Playback playback, List tracks, - {Track? currentTrack}) async { - currentTrack ??= tracks.first; + Future playPlaylist( + Playback playback, + List tracks, + WidgetRef ref, { + Track? currentTrack, + }) async { + final sortBy = ref.read(trackCollectionSortState(playlist.id!)); + final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy); + currentTrack ??= sortedTracks.first; final isPlaylistPlaying = playback.playlist?.id != null && playback.playlist?.id == playlist.id; if (!isPlaylistPlaying) { await playback.playPlaylist( CurrentPlaylist( - tracks: tracks, + tracks: sortedTracks, id: playlist.id!, name: playlist.name!, thumbnail: TypeConversionUtils.image_X_UrlString( @@ -39,7 +47,7 @@ class PlaylistView extends HookConsumerWidget { placeholder: ImagePlaceholder.collection, ), ), - tracks.indexWhere((s) => s.id == currentTrack?.id), + sortedTracks.indexWhere((s) => s.id == currentTrack?.id), ); } else if (isPlaylistPlaying && currentTrack.id != null && @@ -85,14 +93,12 @@ class PlaylistView extends HookConsumerWidget { onPlay: ([track]) { if (tracksSnapshot.asData?.value != null) { if (!isPlaylistPlaying) { - playPlaylist( - playback, - tracksSnapshot.asData!.value, - ); + playPlaylist(playback, tracksSnapshot.asData!.value, ref); } else if (isPlaylistPlaying && track != null) { playPlaylist( playback, tracksSnapshot.asData!.value, + ref, currentTrack: track, ); } else { diff --git a/lib/components/Shared/SortTracksDropdown.dart b/lib/components/Shared/SortTracksDropdown.dart new file mode 100644 index 00000000..2b503613 --- /dev/null +++ b/lib/components/Shared/SortTracksDropdown.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:spotube/components/Library/UserLocalTracks.dart'; + +class SortTracksDropdown extends StatelessWidget { + final SortBy? value; + final void Function(SortBy)? onChanged; + const SortTracksDropdown({ + this.onChanged, + this.value, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: SortBy.none, + enabled: value != SortBy.none, + child: const Text("None"), + ), + PopupMenuItem( + value: SortBy.ascending, + enabled: value != SortBy.ascending, + child: const Text("Sort by A-Z"), + ), + PopupMenuItem( + value: SortBy.descending, + enabled: value != SortBy.descending, + child: const Text("Sort by Z-A"), + ), + PopupMenuItem( + value: SortBy.dateAdded, + enabled: value != SortBy.dateAdded, + child: const Text("Sort by Date"), + ), + PopupMenuItem( + value: SortBy.artist, + enabled: value != SortBy.artist, + child: const Text("Sort by Artist"), + ), + PopupMenuItem( + value: SortBy.album, + enabled: value != SortBy.album, + child: const Text("Sort by Album"), + ), + ]; + }, + onSelected: onChanged, + icon: const Icon(Icons.sort_rounded), + ); + } +} diff --git a/lib/components/Shared/TracksTableView.dart b/lib/components/Shared/TracksTableView.dart index fb4da8f4..60bbd55a 100644 --- a/lib/components/Shared/TracksTableView.dart +++ b/lib/components/Shared/TracksTableView.dart @@ -2,13 +2,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/Library/UserLocalTracks.dart'; import 'package:spotube/components/Shared/DownloadConfirmationDialog.dart'; import 'package:spotube/components/Shared/NotFound.dart'; +import 'package:spotube/components/Shared/SortTracksDropdown.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/provider/Downloader.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/service_utils.dart'; + +final trackCollectionSortState = + StateProvider.family((ref, _) => SortBy.none); class TracksTableView extends HookConsumerWidget { final void Function(Track currentTrack)? onTrackPlayButtonPressed; @@ -39,26 +45,34 @@ class TracksTableView extends HookConsumerWidget { final selected = useState>([]); final showCheck = useState(false); + final sortBy = ref.watch(trackCollectionSortState(playlistId ?? '')); - final selectedTracks = useMemoized( - () => tracks.where( - (track) => selected.value.contains(track.id), - ), - [tracks], + final sortedTracks = useMemoized( + () { + return ServiceUtils.sortTracks(tracks, sortBy); + }, + [tracks, sortBy], ); - final children = tracks.isEmpty + final selectedTracks = useMemoized( + () => sortedTracks.where( + (track) => selected.value.contains(track.id), + ), + [sortedTracks], + ); + + final children = sortedTracks.isEmpty ? [const NotFound(vertical: true)] : [ if (heading != null) heading!, Row( children: [ Checkbox( - value: selected.value.length == tracks.length, + value: selected.value.length == sortedTracks.length, onChanged: (checked) { if (!showCheck.value) showCheck.value = true; if (checked == true) { - selected.value = tracks.map((s) => s.id!).toList(); + selected.value = sortedTracks.map((s) => s.id!).toList(); } else { selected.value = []; showCheck.value = false; @@ -104,11 +118,20 @@ class TracksTableView extends HookConsumerWidget { Text("Time", style: tableHeadStyle), const SizedBox(width: 10), ], + SortTracksDropdown( + value: sortBy, + onChanged: (value) { + ref + .read(trackCollectionSortState(playlistId ?? '').state) + .state = value; + }, + ), PopupMenuButton( itemBuilder: (context) { return [ PopupMenuItem( enabled: selected.value.isNotEmpty, + value: "download", child: Row( children: [ const Icon(Icons.file_download_outlined), @@ -117,7 +140,6 @@ class TracksTableView extends HookConsumerWidget { ), ], ), - value: "download", ), ]; }, @@ -144,7 +166,7 @@ class TracksTableView extends HookConsumerWidget { ), ], ), - ...tracks.asMap().entries.map((track) { + ...sortedTracks.asMap().entries.map((track) { String duration = "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; return InkWell( diff --git a/lib/main.dart b/lib/main.dart index 85cdafb9..35438022 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -173,8 +173,8 @@ class _SpotubeState extends ConsumerState with WidgetsBindingObserver { localStorage!.setString( LocalStorageKeys.windowSizeInfo, jsonEncode({ - 'width': appWindow.isMaximized ? 0 : appWindow.size.width, - 'height': appWindow.isMaximized ? 0 : appWindow.size.height, + 'width': appWindow.isMaximized ? 0.0 : appWindow.size.width, + 'height': appWindow.isMaximized ? 0.0 : appWindow.size.height, }), ); prevSize = appWindow.size; diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 376b7d30..0b54925e 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:html/dom.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/Library/UserLocalTracks.dart'; import 'package:spotube/models/LocalStorageKeys.dart'; import 'package:spotube/models/Logger.dart'; import 'package:http/http.dart' as http; @@ -393,4 +394,31 @@ abstract class ServiceUtils { static void navigate(BuildContext context, String location, {Object? extra}) { GoRouter.of(context).push(location, extra: extra); } + + static List sortTracks(List tracks, SortBy sortBy) { + if (sortBy == SortBy.none) return tracks; + return List.from(tracks) + ..sort((a, b) { + switch (sortBy) { + case SortBy.album: + return a.album?.name?.compareTo(b.album?.name ?? "") ?? 0; + case SortBy.artist: + return a.artists?.first.name + ?.compareTo(b.artists?.first.name ?? "") ?? + 0; + case SortBy.ascending: + return a.name?.compareTo(b.name ?? "") ?? 0; + case SortBy.dateAdded: + final aDate = + double.parse(a.album?.releaseDate?.split("-").first ?? "2069"); + final bDate = + double.parse(b.album?.releaseDate?.split("-").first ?? "2069"); + return aDate.compareTo(bDate); + case SortBy.descending: + return b.name?.compareTo(a.name ?? "") ?? 0; + default: + return 0; + } + }); + } }