diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 0a653012..35ab2a4f 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -1,8 +1,11 @@ import 'dart:io'; import 'package:catcher_2/catcher_2.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:collection/collection.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; @@ -59,84 +62,87 @@ enum SortBy { album, } -final localTracksProvider = FutureProvider>((ref) async { +final localTracksProvider = FutureProvider>>((ref) async { try { - if (kIsWeb) return []; + if (kIsWeb) return {}; + final Map> tracks = {}; + final downloadLocation = ref.watch( userPreferencesProvider.select((s) => s.downloadLocation), ); - if (downloadLocation.isEmpty) return []; final downloadDir = Directory(downloadLocation); if (!await downloadDir.exists()) { await downloadDir.create(recursive: true); - return []; } - final entities = downloadDir.listSync(recursive: true); - final localLibraryLocations = ref.watch( userPreferencesProvider.select((s) => s.localLibraryLocation), ); - for (final location in localLibraryLocations) { + + for (var location in [downloadLocation, ...localLibraryLocations]) { + if (location.isEmpty) continue; + final entities = []; final dir = Directory(location); if (await Directory(location).exists()) { entities.addAll(Directory(location).listSync(recursive: true)); } - } - final filesWithMetadata = (await Future.wait( - entities.map((e) => File(e.path)).where((file) { - final mimetype = lookupMimeType(file.path); - return mimetype != null && supportedAudioTypes.contains(mimetype); - }).map( - (file) async { - try { - final metadata = await MetadataGod.readMetadata(file: file.path); + final filesWithMetadata = (await Future.wait( + entities.map((e) => File(e.path)).where((file) { + final mimetype = lookupMimeType(file.path); + return mimetype != null && supportedAudioTypes.contains(mimetype); + }).map( + (file) async { + try { + final metadata = await MetadataGod.readMetadata(file: file.path); - final imageFile = File(join( - (await getTemporaryDirectory()).path, - "spotube", - basenameWithoutExtension(file.path) + - imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, - )); - if (!await imageFile.exists() && metadata.picture != null) { - await imageFile.create(recursive: true); - await imageFile.writeAsBytes( - metadata.picture?.data ?? [], - mode: FileMode.writeOnly, - ); + final imageFile = File(join( + (await getTemporaryDirectory()).path, + "spotube", + basenameWithoutExtension(file.path) + + imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, + )); + if (!await imageFile.exists() && metadata.picture != null) { + await imageFile.create(recursive: true); + await imageFile.writeAsBytes( + metadata.picture?.data ?? [], + mode: FileMode.writeOnly, + ); + } + + return {"metadata": metadata, "file": file, "art": imageFile.path}; + } catch (e, stack) { + if (e is FfiException) { + return {"file": file}; + } + Catcher2.reportCheckedError(e, stack); + return {}; } + }, + ), + )) + .where((e) => e.isNotEmpty) + .toList(); - return {"metadata": metadata, "file": file, "art": imageFile.path}; - } catch (e, stack) { - if (e is FfiException) { - return {"file": file}; - } - Catcher2.reportCheckedError(e, stack); - return {}; - } - }, - ), - )) - .where((e) => e.isNotEmpty) - .toList(); - - final tracks = filesWithMetadata - .map( - (fileWithMetadata) => LocalTrack.fromTrack( - track: Track().fromFile( - fileWithMetadata["file"], - metadata: fileWithMetadata["metadata"], - art: fileWithMetadata["art"], + // ignore: no_leading_underscores_for_local_identifiers + final _tracks = filesWithMetadata + .map( + (fileWithMetadata) => LocalTrack.fromTrack( + track: Track().fromFile( + fileWithMetadata["file"], + metadata: fileWithMetadata["metadata"], + art: fileWithMetadata["art"], + ), + path: fileWithMetadata["file"].path, ), - path: fileWithMetadata["file"].path, - ), - ) - .toList(); + ) + .toList(); + tracks[location] = _tracks; + } return tracks; } catch (e, stack) { Catcher2.reportCheckedError(e, stack); - return []; + return {}; } }); @@ -171,7 +177,7 @@ class UserLocalTracks extends HookConsumerWidget { final playlist = ref.watch(proxyPlaylistProvider); final trackSnapshot = ref.watch(localTracksProvider); final isPlaylistPlaying = - playlist.containsTracks(trackSnapshot.asData?.value ?? []); + playlist.containsTracks(trackSnapshot.asData?.value.values.flattened.toList() ?? []); final searchController = useTextEditingController(); useValueListenable(searchController); @@ -179,6 +185,31 @@ class UserLocalTracks extends HookConsumerWidget { final isFiltering = useState(false); final controller = useScrollController(); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final preferences = ref.watch(userPreferencesProvider); + + final addLocalLibraryLocation = useCallback(() async { + if (DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS) { + final dirStr = await FilePicker.platform.getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + if (preferences.localLibraryLocation.contains(dirStr)) return; + preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]); + } else { + String? dirStr = await getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + if (preferences.localLibraryLocation.contains(dirStr)) return; + preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]); + } + }, [preferences.localLibraryLocation]); + + final removeLocalLibraryLocation = useCallback((String location) { + if (!preferences.localLibraryLocation.contains(location)) return; + preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation]..remove(location)); + }, [preferences.localLibraryLocation]); return Column( children: [ @@ -194,7 +225,7 @@ class UserLocalTracks extends HookConsumerWidget { if (!isPlaylistPlaying) { await playLocalTracks( ref, - trackSnapshot.asData!.value, + trackSnapshot.asData!.value.values.flattened.toList(), ); } } @@ -222,6 +253,16 @@ class UserLocalTracks extends HookConsumerWidget { sortBy.value = value; }, ), + if (!kIsWeb) ...[ + const SizedBox(width: 10), + Tooltip( + message: context.l10n.add_library_location, + child: IconButton( + onPressed: addLocalLibraryLocation, + icon: const Icon(SpotubeIcons.folder), // TODO: use a "folder add" icon + ), + ), + ], const SizedBox(width: 5), FilledButton( child: const Icon(SpotubeIcons.refresh), @@ -239,86 +280,116 @@ class UserLocalTracks extends HookConsumerWidget { onChangeFiltering: (value) => isFiltering.value = value, ), trackSnapshot.when( - data: (tracks) { - 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 Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [NotFound()], - ), - ); - } - - return Expanded( - child: RefreshIndicator( - onRefresh: () async { - ref.invalidate(localTracksProvider); - }, + data: (groups) => Expanded( + child: RefreshIndicator( + onRefresh: () async { + ref.invalidate(localTracksProvider); + }, + child: Expanded( 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, + 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()), + Tooltip( + message: context.l10n.remove_library_location, + child: IconButton( + icon: Icon(SpotubeIcons.trash, 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,