diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 080cbd8a..84edf3bc 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -36,6 +36,7 @@ import 'package:spotube/pages/desktop_login/desktop_login.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/root/root_app.dart'; import 'package:spotube/pages/settings/settings.dart'; +import 'package:spotube/pages/settings/library.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart'; final rootNavigatorKey = Catcher2.navigatorKey; @@ -132,6 +133,12 @@ final routerProvider = Provider((ref) { child: const BlackListPage(), ), ), + GoRoute( + path: "local_library", + pageBuilder: (context, state) => SpotubeSlidePage( + child: const LocalLibrariesPage(), + ), + ), if (!kIsWeb) GoRoute( path: "logs", diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 42aec124..0a653012 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -71,20 +71,17 @@ final localTracksProvider = FutureProvider>((ref) async { await downloadDir.create(recursive: true); return []; } - final downloadEntities = downloadDir.listSync(recursive: true); + final entities = downloadDir.listSync(recursive: true); - final localLibraryLocation = ref.watch( + final localLibraryLocations = ref.watch( userPreferencesProvider.select((s) => s.localLibraryLocation), ); - if (localLibraryLocation.isEmpty) return []; - final localLibraryDir = Directory(localLibraryLocation); - if (!await localLibraryDir.exists()) { - await localLibraryDir.create(recursive: true); - return []; + for (final location in localLibraryLocations) { + final dir = Directory(location); + if (await Directory(location).exists()) { + entities.addAll(Directory(location).listSync(recursive: true)); + } } - final localLibraryEntities = localLibraryDir.listSync(recursive: true); - - final entities = [...downloadEntities, ...localLibraryEntities]; final filesWithMetadata = (await Future.wait( entities.map((e) => File(e.path)).where((file) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 389bcda4..6729d593 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -107,7 +107,10 @@ "always_on_top": "Always on top", "exit_mini_player": "Exit Mini player", "download_location": "Download location", - "local_library_location": "Local library location", + "local_library": "Local library", + "local_library_description": "Music saved on your device that wasn't downloaded from Spotube", + "add_library_location": "Add to library", + "remove_library_location": "Remove from library", "account": "Account", "login_with_spotify": "Login with your Spotify account", "connect_with_spotify": "Connect with Spotify", diff --git a/lib/pages/settings/library.dart b/lib/pages/settings/library.dart new file mode 100644 index 00000000..c81e3d3d --- /dev/null +++ b/lib/pages/settings/library.dart @@ -0,0 +1,100 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.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'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class LocalLibrariesPage extends HookConsumerWidget { + const LocalLibrariesPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final controller = useScrollController(); + // final blacklist = ref.watch(blacklistProvider); + // final searchText = useState(""); + 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; + preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]); + } else { + String? dirStr = await getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]); + } + }, [preferences.localLibraryLocation]); + + final removeLocalLibraryLocation = useCallback((int index) { + if (index < 0 || index >= preferences.localLibraryLocation.length) return; + preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation]..removeAt(index)); + }, [preferences.localLibraryLocation]); + + return Scaffold( + appBar: PageWindowTitleBar( + title: Text(context.l10n.local_library), + centerTitle: true, + leading: const BackButton(), + ), + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + /* Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + onChanged: (value) => searchText.value = value, + decoration: InputDecoration( + hintText: context.l10n.search, + prefixIcon: const Icon(SpotubeIcons.search), + ), + ), + ), */ + Padding( + padding: const EdgeInsets.all(8.0), + child: FilledButton( + child: const Icon(SpotubeIcons.add), + onPressed: addLocalLibraryLocation, + ), + ), + InterScrollbar( + controller: controller, + child: ListView.builder( + controller: controller, + shrinkWrap: true, + itemCount: preferences.localLibraryLocation.length, + itemBuilder: (context, index) { + final item = preferences.localLibraryLocation.elementAt(index); + return ListTile( + title: Text(item), + trailing: Tooltip( + message: context.l10n.remove_library_location, + child: IconButton( + icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), + onPressed: () => removeLocalLibraryLocation(index), + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index 9c2b34b9..30a730ca 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -3,6 +3,7 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; @@ -33,22 +34,6 @@ class SettingsDownloadsSection extends HookConsumerWidget { } }, [preferences.downloadLocation]); - final pickLocalLibraryLocation = useCallback(() async { - if (DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS) { - final dirStr = await FilePicker.platform.getDirectoryPath( - initialDirectory: preferences.localLibraryLocation, - ); - if (dirStr == null) return; - preferencesNotifier.setLocalLibraryLocation(dirStr); - } else { - String? dirStr = await getDirectoryPath( - initialDirectory: preferences.localLibraryLocation, - ); - if (dirStr == null) return; - preferencesNotifier.setLocalLibraryLocation(dirStr); - } - }, [preferences.localLibraryLocation]); - return SectionCardWithHeading( heading: context.l10n.downloads, children: [ @@ -64,13 +49,12 @@ class SettingsDownloadsSection extends HookConsumerWidget { ), ListTile( leading: const Icon(SpotubeIcons.folder), - title: Text(context.l10n.local_library_location), - subtitle: Text(preferences.localLibraryLocation), - trailing: FilledButton( - onPressed: pickLocalLibraryLocation, - child: const Icon(SpotubeIcons.folder), - ), - onTap: pickLocalLibraryLocation, + title: Text(context.l10n.local_library), + subtitle: Text(context.l10n.local_library_description), + onTap: () { + GoRouter.of(context).push("/settings/local_library"); + }, + trailing: const Icon(SpotubeIcons.angleRight), ), ], ); diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index 44c48d99..d34586f3 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -69,9 +69,9 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(downloadLocation: downloadDir); } - void setLocalLibraryLocation(String localLibraryDir) { - if (localLibraryDir.isEmpty) return; - state = state.copyWith(localLibraryLocation: localLibraryDir); + void setLocalLibraryLocation(List localLibraryDirs) { + //if (localLibraryDir.isEmpty) return; + state = state.copyWith(localLibraryLocation: localLibraryDirs); } void setLayoutMode(LayoutMode mode) { diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index 501cf993..56f66375 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -84,7 +84,7 @@ class UserPreferences with _$UserPreferences { @Default(Market.US) Market recommendationMarket, @Default(SearchMode.youtube) SearchMode searchMode, @Default("") String downloadLocation, - @Default("") String localLibraryLocation, + @Default([]) List localLibraryLocation, @Default("https://pipedapi.kavin.rocks") String pipedInstance, @Default(ThemeMode.system) ThemeMode themeMode, @Default(AudioSource.youtube) AudioSource audioSource, diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart index 4ed22b67..89c7210a 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -43,7 +43,7 @@ mixin _$UserPreferences { Market get recommendationMarket => throw _privateConstructorUsedError; SearchMode get searchMode => throw _privateConstructorUsedError; String get downloadLocation => throw _privateConstructorUsedError; - String get localLibraryLocation => throw _privateConstructorUsedError; + List get localLibraryLocation => throw _privateConstructorUsedError; String get pipedInstance => throw _privateConstructorUsedError; ThemeMode get themeMode => throw _privateConstructorUsedError; AudioSource get audioSource => throw _privateConstructorUsedError; @@ -89,7 +89,7 @@ abstract class $UserPreferencesCopyWith<$Res> { Market recommendationMarket, SearchMode searchMode, String downloadLocation, - String localLibraryLocation, + List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, @@ -202,7 +202,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> localLibraryLocation: null == localLibraryLocation ? _value.localLibraryLocation : localLibraryLocation // ignore: cast_nullable_to_non_nullable - as String, + as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -271,7 +271,7 @@ abstract class _$$UserPreferencesImplCopyWith<$Res> Market recommendationMarket, SearchMode searchMode, String downloadLocation, - String localLibraryLocation, + List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, @@ -380,9 +380,9 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> : downloadLocation // ignore: cast_nullable_to_non_nullable as String, localLibraryLocation: null == localLibraryLocation - ? _value.localLibraryLocation + ? _value._localLibraryLocation : localLibraryLocation // ignore: cast_nullable_to_non_nullable - as String, + as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -446,7 +446,7 @@ class _$UserPreferencesImpl implements _UserPreferences { this.recommendationMarket = Market.US, this.searchMode = SearchMode.youtube, this.downloadLocation = "", - this.localLibraryLocation = "", + final List localLibraryLocation = const [], this.pipedInstance = "https://pipedapi.kavin.rocks", this.themeMode = ThemeMode.system, this.audioSource = AudioSource.youtube, @@ -454,7 +454,8 @@ class _$UserPreferencesImpl implements _UserPreferences { this.downloadMusicCodec = SourceCodecs.m4a, this.discordPresence = true, this.endlessPlayback = true, - this.enableConnect = false}); + this.enableConnect = false}) + : _localLibraryLocation = localLibraryLocation; factory _$UserPreferencesImpl.fromJson(Map json) => _$$UserPreferencesImplFromJson(json); @@ -510,9 +511,16 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final String downloadLocation; + final List _localLibraryLocation; @override @JsonKey() - final String localLibraryLocation; + List get localLibraryLocation { + if (_localLibraryLocation is EqualUnmodifiableListView) + return _localLibraryLocation; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_localLibraryLocation); + } + @override @JsonKey() final String pipedInstance; @@ -577,8 +585,8 @@ class _$UserPreferencesImpl implements _UserPreferences { other.searchMode == searchMode) && (identical(other.downloadLocation, downloadLocation) || other.downloadLocation == downloadLocation) && - (identical(other.localLibraryLocation, localLibraryLocation) || - other.localLibraryLocation == localLibraryLocation) && + const DeepCollectionEquality() + .equals(other._localLibraryLocation, _localLibraryLocation) && (identical(other.pipedInstance, pipedInstance) || other.pipedInstance == pipedInstance) && (identical(other.themeMode, themeMode) || @@ -616,7 +624,7 @@ class _$UserPreferencesImpl implements _UserPreferences { recommendationMarket, searchMode, downloadLocation, - localLibraryLocation, + const DeepCollectionEquality().hash(_localLibraryLocation), pipedInstance, themeMode, audioSource, @@ -667,7 +675,7 @@ abstract class _UserPreferences implements UserPreferences { final Market recommendationMarket, final SearchMode searchMode, final String downloadLocation, - final String localLibraryLocation, + final List localLibraryLocation, final String pipedInstance, final ThemeMode themeMode, final AudioSource audioSource, @@ -719,7 +727,7 @@ abstract class _UserPreferences implements UserPreferences { @override String get downloadLocation; @override - String get localLibraryLocation; + List get localLibraryLocation; @override String get pipedInstance; @override diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index ba27389e..95ed4b03 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -44,7 +44,10 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? SearchMode.youtube, downloadLocation: json['downloadLocation'] as String? ?? "", - localLibraryLocation: json['localLibraryLocation'] as String? ?? "", + localLibraryLocation: (json['localLibraryLocation'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], pipedInstance: json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks", themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? diff --git a/untranslated_messages.json b/untranslated_messages.json index c0b93615..9b6de1c8 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,89 +1,155 @@ { "ar": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "bn": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "ca": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "cs": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "de": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "es": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "fa": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "fr": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "hi": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "it": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "ja": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "ko": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "ne": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "nl": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "pl": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "pt": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "ru": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "th": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "tr": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "uk": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "vi": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ], "zh": [ - "local_library_location" + "local_library", + "local_library_description", + "add_library_location", + "remove_library_location" ] }