From 22caa818f4ac31626aaff6952e43512b42237d00 Mon Sep 17 00:00:00 2001 From: Blake Leonard Date: Thu, 23 May 2024 05:18:01 -0400 Subject: [PATCH] feat: Local music library (#1479) * feat: add one additional library folder This folder just doesn't get downloaded to. I think I'm going to rework it so that it can be multiple folders, but I'm going to commit my progress so far anyway. Signed-off-by: Blake Leonard * chore: update dependencies so that it builds I'm not sure if this breaks CI or something, but I couldn't build it locally to test my changes, so I made these changes and it builds again. Signed-off-by: Blake Leonard * feat: index multiple folders of local music If you used a previous commit from this branch, this is a breaking change, because it changes the type of a configuration field. but since this is still in development, it should be fine. Signed-off-by: Blake Leonard * refactor: manage local library in local tracks tab This also refactors the list to use slivers instead. That's the easiest way to have multiple scrolling lists here... The console keeps getting spammed with some intermediate layout error but I can't hold it long enough to figure out what's causing it. Signed-off-by: Blake Leonard * refactor: use folder add/remove icons in library Signed-off-by: Blake Leonard * refactor: remove redundant settings page Signed-off-by: Blake Leonard * refactor: rename "Local Tracks" to just "Local" Not sure if this would be the recommended way to do it... Signed-off-by: Blake Leonard * fix: console spam about useless Expanded Signed-off-by: Blake Leonard * chore: remove completed TODO Signed-off-by: Blake Leonard * chore: use new Platform constants; regenerate plugins Signed-off-by: Blake Leonard * refactor: put local libraries on separate pages Signed-off-by: Blake Leonard --------- Signed-off-by: Blake Leonard --- lib/collections/routes.dart | 12 + lib/collections/spotube_icons.dart | 2 + lib/components/library/user_local_tracks.dart | 352 +++++++----------- lib/l10n/app_en.arb | 6 +- lib/pages/library/library.dart | 2 +- lib/pages/library/local_folder.dart | 236 ++++++++++++ lib/pages/settings/sections/downloads.dart | 1 + .../user_preferences_provider.dart | 5 + .../user_preferences_state.dart | 1 + .../user_preferences_state.freezed.dart | 35 +- .../user_preferences_state.g.dart | 5 + linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 20 +- pubspec.yaml | 11 + untranslated_messages.json | 156 +++++++- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 19 files changed, 619 insertions(+), 236 deletions(-) create mode 100644 lib/pages/library/local_folder.dart 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/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 6de21284..2da09f52 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -121,4 +121,6 @@ abstract class SpotubeIcons { static const monitor = FeatherIcons.monitor; static const power = FeatherIcons.power; static const bluetooth = FeatherIcons.bluetooth; + static const folderAdd = FeatherIcons.folderPlus; + static const folderRemove = FeatherIcons.folderMinus; } diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index a7b2102b..d5115aaa 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -1,11 +1,14 @@ 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: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'; @@ -27,6 +30,7 @@ import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; // ignore: depend_on_referenced_packages import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; @@ -59,116 +63,125 @@ 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), + ); - 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); + 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 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 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(); - 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 {}; } }); 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 ?? []); - final searchController = useTextEditingController(); - useValueListenable(searchController); - final searchFocus = useFocusNode(); - final isFiltering = useState(false); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final preferences = ref.watch(userPreferencesProvider); - final controller = useScrollController(); + final addLocalLibraryLocation = useCallback(() async { + if (kIsMobile || kIsMacOS) { + 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]); + + // This is just to pre-load the tracks. + // For now, this gets all of them. + ref.watch(localTracksProvider); return Column( children: [ @@ -177,155 +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, - ); - } - } - } - : 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); - }, + TextButton.icon( + icon: const Icon(SpotubeIcons.folderAdd), + label: Text(context.l10n.add_library_location), + onPressed: addLocalLibraryLocation, ) - ], - ), + ] + ) ), - ExpandableSearchField( - searchController: searchController, - searchFocus: searchFocus, - isFiltering: isFiltering.value, - 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; + 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 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 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 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()), - ) - ], + ), + ] ); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 832862c0..a90fd35e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -107,6 +107,9 @@ "always_on_top": "Always on top", "exit_mini_player": "Exit Mini player", "download_location": "Download location", + "local_library": "Local library", + "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", @@ -295,6 +298,7 @@ "delete_playlist": "Delete Playlist", "delete_playlist_confirmation": "Are you sure you want to delete this playlist?", "local_tracks": "Local Tracks", + "local_tab": "Local", "song_link": "Song Link", "skip_this_nonsense": "Skip this nonsense", "freedom_of_music": "“Freedom of Music”", @@ -321,4 +325,4 @@ "connect_client_alert": "You're being controlled by {client}", "this_device": "This Device", "remote": "Remote" -} \ No newline at end of file +} diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index ccdb6a35..eff30348 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -27,7 +27,7 @@ class LibraryPage extends HookConsumerWidget { leading: ThemedButtonsTabBar( tabs: [ Tab(text: " ${context.l10n.playlists} "), - Tab(text: " ${context.l10n.local_tracks} "), + Tab(text: " ${context.l10n.local_tab} "), Tab( child: Badge( isLabelVisible: downloadingCount > 0, 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()), + ) + ], + ) + ), + ); + } +} diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index 76ef8e3e..3092ed03 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'; diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index a537038e..d34586f3 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -69,6 +69,11 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(downloadLocation: downloadDir); } + void setLocalLibraryLocation(List localLibraryDirs) { + //if (localLibraryDir.isEmpty) return; + state = state.copyWith(localLibraryLocation: localLibraryDirs); + } + void setLayoutMode(LayoutMode mode) { state = state.copyWith(layoutMode: mode); } diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index 67eb18a2..56f66375 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -84,6 +84,7 @@ class UserPreferences with _$UserPreferences { @Default(Market.US) Market recommendationMarket, @Default(SearchMode.youtube) SearchMode searchMode, @Default("") String downloadLocation, + @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 94015d37..89c7210a 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -43,6 +43,7 @@ mixin _$UserPreferences { Market get recommendationMarket => throw _privateConstructorUsedError; SearchMode get searchMode => throw _privateConstructorUsedError; String get downloadLocation => throw _privateConstructorUsedError; + List get localLibraryLocation => throw _privateConstructorUsedError; String get pipedInstance => throw _privateConstructorUsedError; ThemeMode get themeMode => throw _privateConstructorUsedError; AudioSource get audioSource => throw _privateConstructorUsedError; @@ -88,6 +89,7 @@ abstract class $UserPreferencesCopyWith<$Res> { Market recommendationMarket, SearchMode searchMode, String downloadLocation, + List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, @@ -126,6 +128,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> Object? recommendationMarket = null, Object? searchMode = null, Object? downloadLocation = null, + Object? localLibraryLocation = null, Object? pipedInstance = null, Object? themeMode = null, Object? audioSource = null, @@ -196,6 +199,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> ? _value.downloadLocation : downloadLocation // ignore: cast_nullable_to_non_nullable as String, + localLibraryLocation: null == localLibraryLocation + ? _value.localLibraryLocation + : localLibraryLocation // ignore: cast_nullable_to_non_nullable + as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -264,6 +271,7 @@ abstract class _$$UserPreferencesImplCopyWith<$Res> Market recommendationMarket, SearchMode searchMode, String downloadLocation, + List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, @@ -300,6 +308,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> Object? recommendationMarket = null, Object? searchMode = null, Object? downloadLocation = null, + Object? localLibraryLocation = null, Object? pipedInstance = null, Object? themeMode = null, Object? audioSource = null, @@ -370,6 +379,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> ? _value.downloadLocation : downloadLocation // ignore: cast_nullable_to_non_nullable as String, + localLibraryLocation: null == localLibraryLocation + ? _value._localLibraryLocation + : localLibraryLocation // ignore: cast_nullable_to_non_nullable + as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -433,6 +446,7 @@ class _$UserPreferencesImpl implements _UserPreferences { this.recommendationMarket = Market.US, this.searchMode = SearchMode.youtube, this.downloadLocation = "", + final List localLibraryLocation = const [], this.pipedInstance = "https://pipedapi.kavin.rocks", this.themeMode = ThemeMode.system, this.audioSource = AudioSource.youtube, @@ -440,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); @@ -496,6 +511,16 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final String downloadLocation; + final List _localLibraryLocation; + @override + @JsonKey() + List get localLibraryLocation { + if (_localLibraryLocation is EqualUnmodifiableListView) + return _localLibraryLocation; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_localLibraryLocation); + } + @override @JsonKey() final String pipedInstance; @@ -523,7 +548,7 @@ class _$UserPreferencesImpl implements _UserPreferences { @override String toString() { - return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; + return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, localLibraryLocation: $localLibraryLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; } @override @@ -560,6 +585,8 @@ class _$UserPreferencesImpl implements _UserPreferences { other.searchMode == searchMode) && (identical(other.downloadLocation, downloadLocation) || other.downloadLocation == downloadLocation) && + const DeepCollectionEquality() + .equals(other._localLibraryLocation, _localLibraryLocation) && (identical(other.pipedInstance, pipedInstance) || other.pipedInstance == pipedInstance) && (identical(other.themeMode, themeMode) || @@ -597,6 +624,7 @@ class _$UserPreferencesImpl implements _UserPreferences { recommendationMarket, searchMode, downloadLocation, + const DeepCollectionEquality().hash(_localLibraryLocation), pipedInstance, themeMode, audioSource, @@ -647,6 +675,7 @@ abstract class _UserPreferences implements UserPreferences { final Market recommendationMarket, final SearchMode searchMode, final String downloadLocation, + final List localLibraryLocation, final String pipedInstance, final ThemeMode themeMode, final AudioSource audioSource, @@ -698,6 +727,8 @@ abstract class _UserPreferences implements UserPreferences { @override String get downloadLocation; @override + List get localLibraryLocation; + @override String get pipedInstance; @override ThemeMode get themeMode; diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index 930b1dd1..95ed4b03 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -44,6 +44,10 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? SearchMode.youtube, downloadLocation: json['downloadLocation'] 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']) ?? @@ -81,6 +85,7 @@ Map _$$UserPreferencesImplToJson( 'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!, 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, 'downloadLocation': instance.downloadLocation, + 'localLibraryLocation': instance.localLibraryLocation, 'pipedInstance': instance.pipedInstance, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 6dfdd740..2f61edd6 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) system_theme_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin"); system_theme_plugin_register_with_registrar(system_theme_registrar); + g_autoptr(FlPluginRegistrar) system_tray_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SystemTrayPlugin"); + system_tray_plugin_register_with_registrar(system_tray_registrar); g_autoptr(FlPluginRegistrar) tray_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); tray_manager_plugin_register_with_registrar(tray_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 93ffd3e9..48c7e0ca 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_linux screen_retriever system_theme + system_tray tray_manager url_launcher_linux window_manager diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 84f39341..0057db14 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -21,6 +21,7 @@ import screen_retriever import shared_preferences_foundation import sqflite import system_theme +import system_tray import tray_manager import url_launcher_macos import window_manager @@ -43,6 +44,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) + SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index df623b9e..61de3f25 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1455,12 +1455,13 @@ packages: source: hosted version: "1.0.9" media_kit_native_event_loop: - dependency: transitive + dependency: "direct overridden" description: - name: media_kit_native_event_loop - sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e - url: "https://pub.dev" - source: hosted + path: media_kit_native_event_loop + ref: main + resolved-ref: "285f7919bbf4a7d89a62615b14a3766a171ad575" + url: "https://github.com/media-kit/media-kit" + source: git version: "1.0.8" menu_base: dependency: transitive @@ -2156,6 +2157,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" + system_tray: + dependency: "direct overridden" + description: + path: "." + ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c + resolved-ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c + url: "https://github.com/antler119/system_tray" + source: git + version: "2.0.2" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7435e077..dc60abf6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -150,6 +150,17 @@ dev_dependencies: dependency_overrides: uuid: ^4.4.0 + system_tray: + # TODO: remove this when flutter_desktop_tools gets updated + # to use [MenuItemBase] instead of [MenuItem] + git: + url: https://github.com/antler119/system_tray + ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c + media_kit_native_event_loop: # to fix "macro name must be an identifier" + git: + url: https://github.com/media-kit/media-kit + path: media_kit_native_event_loop + ref: main flutter: generate: true diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfee..91b751eb 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,155 @@ -{} \ No newline at end of file +{ + "ar": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "bn": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "ca": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "cs": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "de": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "es": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "fa": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "fr": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "hi": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "it": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "ja": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "ko": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "ne": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "nl": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "pl": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "pt": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "ru": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "th": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "tr": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "uk": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "vi": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "zh": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ] +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 57542dec..f2dd9714 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); SystemThemePluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SystemThemePlugin")); + SystemTrayPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SystemTrayPlugin")); TrayManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 6a0c7723..f4e14280 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -13,6 +13,7 @@ list(APPEND FLUTTER_PLUGIN_LIST permission_handler_windows screen_retriever system_theme + system_tray tray_manager url_launcher_windows window_manager