From fc5bfa089ce2f46ab786565d6750564d704ee7e0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 23 May 2024 21:27:09 +0600 Subject: [PATCH] feat: local library folder cards --- .../local_folder/local_folder_item.dart | 199 ++++++++++++++++ lib/components/library/user_local_tracks.dart | 214 ++++-------------- .../shared/track_tile/track_options.dart | 2 +- .../configurators/use_get_storage_perms.dart | 2 +- lib/pages/library/local_folder.dart | 1 + .../local_tracks/local_tracks_provider.dart | 125 ++++++++++ .../proxy_playlist/proxy_playlist.dart | 4 +- macos/Podfile.lock | 6 + 8 files changed, 380 insertions(+), 173 deletions(-) create mode 100644 lib/components/library/local_folder/local_folder_item.dart create mode 100644 lib/provider/local_tracks/local_tracks_provider.dart diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/components/library/local_folder/local_folder_item.dart new file mode 100644 index 00000000..281cfc2c --- /dev/null +++ b/lib/components/library/local_folder/local_folder_item.dart @@ -0,0 +1,199 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:path/path.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class LocalFolderItem extends HookConsumerWidget { + final String folder; + const LocalFolderItem({super.key, required this.folder}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme) = Theme.of(context); + final mediaQuery = MediaQuery.of(context); + + final lerpValue = useBrightnessValue(.9, .7); + + final downloadFolder = + ref.watch(userPreferencesProvider.select((s) => s.downloadLocation)); + + final isDownloadFolder = folder == downloadFolder; + + final Uri(:pathSegments) = Uri.parse( + folder + .replaceFirst(RegExp(r'^/Volumes/[^/]+/Users/'), "") + .replaceFirst(r'C:\Users\', "") + .replaceFirst(r'/home/', ""), + ); + + // if length > 5, we ... all the middle segments after 2 and the last 2 + final segments = pathSegments.length > 5 + ? [ + ...pathSegments.take(2), + "...", + ...pathSegments.skip(pathSegments.length - 3).toList() + ..removeLast(), + ] + : pathSegments.take(pathSegments.length - 1).toList(); + + final trackSnapshot = ref.watch( + localTracksProvider.select( + (s) => s.whenData((tracks) => tracks[folder]?.take(4).toList()), + ), + ); + + final tracks = trackSnapshot.value ?? []; + + return InkWell( + onTap: () { + if (isDownloadFolder) { + context.go("/library/local?downloads=1", extra: folder); + } else { + context.go( + "/library/local", + extra: folder, + ); + } + }, + borderRadius: BorderRadius.circular(8), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Color.lerp( + colorScheme.surfaceVariant, + colorScheme.surface, + lerpValue, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (tracks.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + SpotubeIcons.folder, + size: mediaQuery.smAndDown + ? 95 + : mediaQuery.mdAndDown + ? 100 + : 142, + ), + ), + ) + else + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: max((tracks.length / 2).ceil(), 2), + ), + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + return UniversalImage( + path: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ); + }, + ), + ), + const Gap(8), + Stack( + children: [ + Center( + child: Text( + isDownloadFolder + ? context.l10n.downloads + : basename(folder), + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + if (!isDownloadFolder) + Align( + alignment: Alignment.topRight, + child: PopupMenuButton( + child: const Padding( + padding: EdgeInsets.all(3), + child: Icon(Icons.more_vert), + ), + itemBuilder: (context) { + return [ + PopupMenuItem( + child: ListTile( + leading: const Icon(SpotubeIcons.folderRemove), + iconColor: colorScheme.error, + title: + Text(context.l10n.remove_library_location), + onTap: () { + final libraryLocations = ref + .read(userPreferencesProvider) + .localLibraryLocation; + ref + .read(userPreferencesProvider.notifier) + .setLocalLibraryLocation( + libraryLocations + .where((e) => e != folder) + .toList(), + ); + }, + ), + ) + ]; + }, + ), + ), + ], + ), + const Spacer(), + Wrap( + spacing: 2, + runSpacing: 2, + children: [ + for (final MapEntry(key: index, value: segment) + in segments.asMap().entries) + Text.rich( + TextSpan( + children: [ + if (index != 0) + TextSpan( + text: "/ ", + style: TextStyle(color: colorScheme.primary), + ), + TextSpan(text: segment), + ], + ), + style: TextStyle( + fontSize: 10, + color: colorScheme.tertiary, + ), + ), + ], + ), + const Spacer(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index ffaae0d9..c0d63380 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -1,44 +1,18 @@ -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_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:metadata_god/metadata_god.dart'; -import 'package:mime/mime.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/library/local_folder/local_folder_item.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; // ignore: depend_on_referenced_packages -import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; - -const supportedAudioTypes = [ - "audio/webm", - "audio/ogg", - "audio/mpeg", - "audio/mp4", - "audio/opus", - "audio/wav", - "audio/aac", -]; - -const imgMimeToExt = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/webp": ".webp", - "image/gif": ".gif", -}; enum SortBy { none, @@ -51,94 +25,6 @@ enum SortBy { album, } -final localTracksProvider = - FutureProvider>>((ref) async { - try { - if (kIsWeb) return {}; - final Map> tracks = {}; - - final downloadLocation = ref.watch( - userPreferencesProvider.select((s) => s.downloadLocation), - ); - final downloadDir = Directory(downloadLocation); - if (!await downloadDir.exists()) { - await downloadDir.create(recursive: true); - } - final localLibraryLocations = ref.watch( - userPreferencesProvider.select((s) => s.localLibraryLocation), - ); - - for (var location in [downloadLocation, ...localLibraryLocations]) { - if (location.isEmpty) continue; - final entities = []; - 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 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(); - - // 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, - ), - ) - .toList(); - - tracks[location] = _tracks; - } - return tracks; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - return {}; - } -}); - class UserLocalTracks extends HookConsumerWidget { const UserLocalTracks({super.key}); @@ -167,61 +53,49 @@ class UserLocalTracks extends HookConsumerWidget { } }, [preferences.localLibraryLocation]); - final removeLocalLibraryLocation = useCallback((String location) { - if (!preferences.localLibraryLocation.contains(location)) return; - 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( - padding: const EdgeInsets.all(8.0), - child: Row(children: [ - const SizedBox(width: 5), - 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, - ); - }); - }), - ), - ]); + return LayoutBuilder(builder: (context, constrains) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(SpotubeIcons.folderAdd), + label: Text(context.l10n.add_library_location), + onPressed: addLocalLibraryLocation, + ), + ), + const Gap(8), + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.isXs + ? 210 + : constrains.mdAndDown + ? 280 + : 250, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: preferences.localLibraryLocation.length + 1, + itemBuilder: (context, index) { + return LocalFolderItem( + folder: index == 0 + ? preferences.downloadLocation + : preferences.localLibraryLocation[index - 1], + ); + }, + ), + ), + ], + ), + ); + }); } } diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index c917ebaa..4b383c47 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -8,7 +8,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; @@ -23,6 +22,7 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart index bcc34042..9cccbfe0 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -3,8 +3,8 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/utils/platform.dart'; void useGetStoragePermissions(WidgetRef ref) { diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart index 7a975935..6552bb5b 100644 --- a/lib/pages/library/local_folder.dart +++ b/lib/pages/library/local_folder.dart @@ -16,6 +16,7 @@ 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/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart new file mode 100644 index 00000000..867774bd --- /dev/null +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:metadata_god/metadata_god.dart'; +import 'package:mime/mime.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +// ignore: depend_on_referenced_packages +import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; + +const supportedAudioTypes = [ + "audio/webm", + "audio/ogg", + "audio/mpeg", + "audio/mp4", + "audio/opus", + "audio/wav", + "audio/aac", +]; + +const imgMimeToExt = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", + "image/gif": ".gif", +}; + +final localTracksProvider = + FutureProvider>>((ref) async { + try { + if (kIsWeb) return {}; + final Map> tracks = {}; + + final downloadLocation = ref.watch( + userPreferencesProvider.select((s) => s.downloadLocation), + ); + final downloadDir = Directory(downloadLocation); + if (!await downloadDir.exists()) { + await downloadDir.create(recursive: true); + } + final localLibraryLocations = ref.watch( + userPreferencesProvider.select((s) => s.localLibraryLocation), + ); + + for (var location in [downloadLocation, ...localLibraryLocations]) { + if (location.isEmpty) continue; + final entities = []; + if (await Directory(location).exists()) { + try { + entities.addAll(Directory(location).listSync(recursive: true)); + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + } + } + + 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, + ); + } + + 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(); + + // 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, + ), + ) + .toList(); + + tracks[location] = _tracks; + } + return tracks; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + return {}; + } +}); diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index b2241ad7..1378c589 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -71,8 +71,10 @@ class ProxyPlaylist { /// To make sure proper instance method is used for JSON serialization /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { - return switch (track.runtimeType) { + return switch (track) { + // ignore: unnecessary_cast LocalTrack() => (track as LocalTrack).toJson(), + // ignore: unnecessary_cast SourcedTrack() => (track as SourcedTrack).toJson(), _ => track.toJson(), }; diff --git a/macos/Podfile.lock b/macos/Podfile.lock index ce2ef233..166bfa71 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -41,6 +41,8 @@ PODS: - FlutterMacOS - system_theme (0.0.1): - FlutterMacOS + - system_tray (0.0.1): + - FlutterMacOS - tray_manager (0.0.1): - FlutterMacOS - url_launcher_macos (0.0.1): @@ -70,6 +72,7 @@ DEPENDENCIES: - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) + - system_tray (from `Flutter/ephemeral/.symlinks/plugins/system_tray/macos`) - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) @@ -118,6 +121,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin system_theme: :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos + system_tray: + :path: Flutter/ephemeral/.symlinks/plugins/system_tray/macos tray_manager: :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos url_launcher_macos: @@ -148,6 +153,7 @@ SPEC CHECKSUMS: shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc + system_tray: e53c972838c69589ff2e77d6d3abfd71332f9e5d tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8