diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 080cbd8a..340b816a 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -14,6 +14,7 @@ import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/lastfm_login/lastfm_login.dart'; +import 'package:spotube/pages/library/local_folder.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart'; @@ -113,6 +114,17 @@ final routerProvider = Provider((ref) { ), ), ]), + GoRoute( + path: "local", + pageBuilder: (context, state) { + assert(state.extra is String); + return SpotubePage( + child: LocalLibraryPage(state.extra as String, + isDownloads: state.uri.queryParameters["downloads"] != null + ), + ); + }, + ), ]), GoRoute( path: "/lyrics", diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 0d7cc7fd..d5115aaa 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:collection/collection.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:mime/mime.dart'; @@ -149,45 +150,12 @@ final localTracksProvider = FutureProvider>>((ref) class UserLocalTracks extends HookConsumerWidget { const UserLocalTracks({super.key}); - Future playLocalTracks( - WidgetRef ref, - List tracks, { - LocalTrack? currentTrack, - }) async { - final playlist = ref.read(proxyPlaylistProvider); - final playback = ref.read(proxyPlaylistProvider.notifier); - currentTrack ??= tracks.first; - final isPlaylistPlaying = playlist.containsTracks(tracks); - if (!isPlaylistPlaying) { - await playback.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } - @override Widget build(BuildContext context, ref) { - final sortBy = useState(SortBy.none); - final playlist = ref.watch(proxyPlaylistProvider); - final trackSnapshot = ref.watch(localTracksProvider); - final isPlaylistPlaying = - playlist.containsTracks(trackSnapshot.asData?.value.values.flattened.toList() ?? []); - final searchController = useTextEditingController(); - useValueListenable(searchController); - final searchFocus = useFocusNode(); - final isFiltering = useState(false); - - final controller = useScrollController(); final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); final preferences = ref.watch(userPreferencesProvider); - + final addLocalLibraryLocation = useCallback(() async { if (kIsMobile || kIsMacOS) { final dirStr = await FilePicker.platform.getDirectoryPath( @@ -211,6 +179,10 @@ class UserLocalTracks extends HookConsumerWidget { preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation]..remove(location)); }, [preferences.localLibraryLocation]); + // This is just to pre-load the tracks. + // For now, this gets all of them. + ref.watch(localTracksProvider); + return Column( children: [ Padding( @@ -218,193 +190,42 @@ class UserLocalTracks extends HookConsumerWidget { child: Row( children: [ const SizedBox(width: 5), - FilledButton( - onPressed: trackSnapshot.asData?.value != null - ? () async { - if (trackSnapshot.asData?.value.isNotEmpty == true) { - if (!isPlaylistPlaying) { - await playLocalTracks( - ref, - trackSnapshot.asData!.value.values.flattened.toList(), - ); - } - } - } - : null, - child: Row( - children: [ - Text(context.l10n.play), - Icon( - isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, - ) - ], - ), - ), - const Spacer(), - ExpandableSearchButton( - isFiltering: isFiltering.value, - onPressed: (value) => isFiltering.value = value, - searchFocus: searchFocus, - ), - const SizedBox(width: 10), - SortTracksDropdown( - value: sortBy.value, - onChanged: (value) { - sortBy.value = value; - }, - ), - if (!kIsWeb) ...[ - const SizedBox(width: 10), - Tooltip( - message: context.l10n.add_library_location, - child: IconButton( - onPressed: addLocalLibraryLocation, - icon: const Icon(SpotubeIcons.folderAdd), - ), - ), - ], - const SizedBox(width: 5), - FilledButton( - child: const Icon(SpotubeIcons.refresh), - onPressed: () { - ref.invalidate(localTracksProvider); - }, + TextButton.icon( + icon: const Icon(SpotubeIcons.folderAdd), + label: Text(context.l10n.add_library_location), + onPressed: addLocalLibraryLocation, ) - ], + ] + ) + ), + Expanded( + child: ListView.builder( + itemCount: preferences.localLibraryLocation.length+1, + itemBuilder: (context, index) { + late final String location; + if (index == 0) { + location = preferences.downloadLocation; + } else { + location = preferences.localLibraryLocation[index-1]; + } + return ListTile( + title: preferences.downloadLocation != location ? Text(location) + : Text(context.l10n.downloads), + trailing: preferences.downloadLocation != location ? Tooltip( + message: context.l10n.remove_library_location, + child: IconButton( + icon: Icon(SpotubeIcons.folderRemove, color: Colors.red[400]), + onPressed: () => removeLocalLibraryLocation(location), + ), + ) : null, + onTap: () async { + context.go("/library/local${location == preferences.downloadLocation ? "?downloads=1" : ""}", extra: location); + } + ); + } ), ), - ExpandableSearchField( - searchController: searchController, - searchFocus: searchFocus, - isFiltering: isFiltering.value, - onChangeFiltering: (value) => isFiltering.value = value, - ), - trackSnapshot.when( - data: (groups) => Expanded( - child: RefreshIndicator( - onRefresh: () async { - ref.invalidate(localTracksProvider); - }, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - for (var MapEntry(key: location, value: tracks) in groups.entries) ...[ - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - sliver: SliverToBoxAdapter( - child: Row( - children: [ - Text(preferences.downloadLocation == location ? context.l10n.downloads : location, - style: Theme.of(context).textTheme.titleLarge - ), - const Expanded(child: SizedBox()), - if (preferences.downloadLocation != location) Tooltip( - message: context.l10n.remove_library_location, - child: IconButton( - icon: Icon(SpotubeIcons.folderRemove, color: Colors.red[400]), - onPressed: () => removeLocalLibraryLocation(location), - ), - ), - ], - ), - ), - ), - HookBuilder( - key: ValueKey("LocalTracks\$$location"), - builder: (context) { - final sortedTracks = useMemoized(() { - return ServiceUtils.sortTracks(tracks, sortBy.value); - }, [sortBy.value, tracks]); - - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return sortedTracks; - } - return sortedTracks - .map((e) => ( - weightedRatio( - "${e.name} - ${e.artists?.asString() ?? ""}", - searchController.text, - ), - e, - )) - .toList() - .sorted( - (a, b) => b.$1.compareTo(a.$1), - ) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList() - .toList(); - }, [searchController.text, sortedTracks]); - - if (!trackSnapshot.isLoading && filteredTracks.isEmpty) { - return const SliverFillRemaining( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [NotFound()], - ), - ); - } - return SliverSkeletonizer( - enabled: trackSnapshot.isLoading, - child: SliverList.builder( - itemCount: - trackSnapshot.isLoading ? 5 : filteredTracks.length, - itemBuilder: (context, index) { - if (trackSnapshot.isLoading) { - return TrackTile( - playlist: playlist, - track: FakeData.track, - index: index, - ); - } - - final track = filteredTracks[index]; - return TrackTile( - index: index, - playlist: playlist, - track: track, - userPlaylist: false, - onTap: () async { - await playLocalTracks( - ref, - sortedTracks, - currentTrack: track, - ); - }, - ); - }, - ), - ); - } - ) - ] - ] - ) - ), - ), - ), - loading: () => Expanded( - child: Skeletonizer( - enabled: true, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) => TrackTile( - track: FakeData.track, - index: index, - playlist: playlist, - ), - ), - ), - ), - error: (error, stackTrace) => - Text(error.toString() + stackTrace.toString()), - ) - ], + ] ); } } diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart new file mode 100644 index 00000000..89d70e09 --- /dev/null +++ b/lib/pages/library/local_folder.dart @@ -0,0 +1,236 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/library/user_local_tracks.dart'; +import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; +import 'package:spotube/components/shared/fallbacks/not_found.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class LocalLibraryPage extends HookConsumerWidget { + final String location; + final bool isDownloads; + const LocalLibraryPage(this.location, {super.key, this.isDownloads = false}); + + Future playLocalTracks( + WidgetRef ref, + List tracks, { + LocalTrack? currentTrack, + }) async { + final playlist = ref.read(proxyPlaylistProvider); + final playback = ref.read(proxyPlaylistProvider.notifier); + currentTrack ??= tracks.first; + final isPlaylistPlaying = playlist.containsTracks(tracks); + if (!isPlaylistPlaying) { + await playback.load( + tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playback.jumpToTrack(currentTrack); + } + } + + @override + Widget build(BuildContext context, ref) { + final sortBy = useState(SortBy.none); + final playlist = ref.watch(proxyPlaylistProvider); + final trackSnapshot = ref.watch(localTracksProvider); + final isPlaylistPlaying = + playlist.containsTracks(trackSnapshot.asData?.value.values.flattened.toList() ?? []); + + final searchController = useTextEditingController(); + useValueListenable(searchController); + final searchFocus = useFocusNode(); + final isFiltering = useState(false); + + final controller = useScrollController(); + + return SafeArea( + bottom: false, + child: Scaffold( + appBar: PageWindowTitleBar( + leading: const BackButton(), + centerTitle: true, + title: Text(isDownloads ? context.l10n.downloads : location), + backgroundColor: Colors.transparent, + ), + extendBodyBehindAppBar: true, + body: Column( + children: [ + const SizedBox(height: 56), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const SizedBox(width: 5), + FilledButton( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot.asData?.value.isNotEmpty == true) { + if (!isPlaylistPlaying) { + await playLocalTracks( + ref, + trackSnapshot.asData!.value[location] ?? [], + ); + } + } + } + : null, + child: Row( + children: [ + Text(context.l10n.play), + Icon( + isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, + ) + ], + ), + ), + const Spacer(), + ExpandableSearchButton( + isFiltering: isFiltering.value, + onPressed: (value) => isFiltering.value = value, + searchFocus: searchFocus, + ), + const SizedBox(width: 10), + SortTracksDropdown( + value: sortBy.value, + onChanged: (value) { + sortBy.value = value; + }, + ), + const SizedBox(width: 5), + FilledButton( + child: const Icon(SpotubeIcons.refresh), + onPressed: () { + ref.invalidate(localTracksProvider); + }, + ) + ], + ), + ), + ExpandableSearchField( + searchController: searchController, + searchFocus: searchFocus, + isFiltering: isFiltering.value, + onChangeFiltering: (value) => isFiltering.value = value, + ), + trackSnapshot.when( + data: (tracks) { + final sortedTracks = useMemoized(() { + return ServiceUtils.sortTracks(tracks[location] ?? [], sortBy.value); + }, [sortBy.value, tracks]); + + final filteredTracks = useMemoized(() { + if (searchController.text.isEmpty) { + return sortedTracks; + } + return sortedTracks + .map((e) => ( + weightedRatio( + "${e.name} - ${e.artists?.asString() ?? ""}", + searchController.text, + ), + e, + )) + .toList() + .sorted( + (a, b) => b.$1.compareTo(a.$1), + ) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() + .toList(); + }, [searchController.text, sortedTracks]); + + if (!trackSnapshot.isLoading && filteredTracks.isEmpty) { + return const Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [NotFound()], + ), + ); + } + + return Expanded( + child: RefreshIndicator( + onRefresh: () async { + ref.invalidate(localTracksProvider); + }, + child: InterScrollbar( + controller: controller, + child: Skeletonizer( + enabled: trackSnapshot.isLoading, + child: ListView.builder( + controller: controller, + physics: const AlwaysScrollableScrollPhysics(), + itemCount: + trackSnapshot.isLoading ? 5 : filteredTracks.length, + itemBuilder: (context, index) { + if (trackSnapshot.isLoading) { + return TrackTile( + playlist: playlist, + track: FakeData.track, + index: index, + ); + } + + final track = filteredTracks[index]; + return TrackTile( + index: index, + playlist: playlist, + track: track, + userPlaylist: false, + onTap: () async { + await playLocalTracks( + ref, + sortedTracks, + currentTrack: track, + ); + }, + ); + }, + ), + ), + ), + ), + ); + }, + loading: () => Expanded( + child: Skeletonizer( + enabled: true, + child: ListView.builder( + itemCount: 5, + itemBuilder: (context, index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), + ), + ), + ), + error: (error, stackTrace) => + Text(error.toString() + stackTrace.toString()), + ) + ], + ) + ), + ); + } +}