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 <me@blakes.dev>
This commit is contained in:
Blake Leonard 2024-05-05 17:06:43 -04:00
parent b051ad4637
commit 20be5dbbcc
No known key found for this signature in database
GPG Key ID: 3B1965C22D07D9F6
10 changed files with 243 additions and 75 deletions

View File

@ -36,6 +36,7 @@ import 'package:spotube/pages/desktop_login/desktop_login.dart';
import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/pages/root/root_app.dart'; import 'package:spotube/pages/root/root_app.dart';
import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/pages/settings/settings.dart';
import 'package:spotube/pages/settings/library.dart';
import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart';
final rootNavigatorKey = Catcher2.navigatorKey; final rootNavigatorKey = Catcher2.navigatorKey;
@ -132,6 +133,12 @@ final routerProvider = Provider((ref) {
child: const BlackListPage(), child: const BlackListPage(),
), ),
), ),
GoRoute(
path: "local_library",
pageBuilder: (context, state) => SpotubeSlidePage(
child: const LocalLibrariesPage(),
),
),
if (!kIsWeb) if (!kIsWeb)
GoRoute( GoRoute(
path: "logs", path: "logs",

View File

@ -71,20 +71,17 @@ final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
await downloadDir.create(recursive: true); await downloadDir.create(recursive: true);
return []; 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), userPreferencesProvider.select((s) => s.localLibraryLocation),
); );
if (localLibraryLocation.isEmpty) return []; for (final location in localLibraryLocations) {
final localLibraryDir = Directory(localLibraryLocation); final dir = Directory(location);
if (!await localLibraryDir.exists()) { if (await Directory(location).exists()) {
await localLibraryDir.create(recursive: true); entities.addAll(Directory(location).listSync(recursive: true));
return []; }
} }
final localLibraryEntities = localLibraryDir.listSync(recursive: true);
final entities = [...downloadEntities, ...localLibraryEntities];
final filesWithMetadata = (await Future.wait( final filesWithMetadata = (await Future.wait(
entities.map((e) => File(e.path)).where((file) { entities.map((e) => File(e.path)).where((file) {

View File

@ -107,7 +107,10 @@
"always_on_top": "Always on top", "always_on_top": "Always on top",
"exit_mini_player": "Exit Mini player", "exit_mini_player": "Exit Mini player",
"download_location": "Download location", "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", "account": "Account",
"login_with_spotify": "Login with your Spotify account", "login_with_spotify": "Login with your Spotify account",
"connect_with_spotify": "Connect with Spotify", "connect_with_spotify": "Connect with Spotify",

View File

@ -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),
),
),
);
},
),
),
],
),
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/collections/spotube_icons.dart';
import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
@ -33,22 +34,6 @@ class SettingsDownloadsSection extends HookConsumerWidget {
} }
}, [preferences.downloadLocation]); }, [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( return SectionCardWithHeading(
heading: context.l10n.downloads, heading: context.l10n.downloads,
children: [ children: [
@ -64,13 +49,12 @@ class SettingsDownloadsSection extends HookConsumerWidget {
), ),
ListTile( ListTile(
leading: const Icon(SpotubeIcons.folder), leading: const Icon(SpotubeIcons.folder),
title: Text(context.l10n.local_library_location), title: Text(context.l10n.local_library),
subtitle: Text(preferences.localLibraryLocation), subtitle: Text(context.l10n.local_library_description),
trailing: FilledButton( onTap: () {
onPressed: pickLocalLibraryLocation, GoRouter.of(context).push("/settings/local_library");
child: const Icon(SpotubeIcons.folder), },
), trailing: const Icon(SpotubeIcons.angleRight),
onTap: pickLocalLibraryLocation,
), ),
], ],
); );

View File

@ -69,9 +69,9 @@ class UserPreferencesNotifier extends PersistedStateNotifier<UserPreferences> {
state = state.copyWith(downloadLocation: downloadDir); state = state.copyWith(downloadLocation: downloadDir);
} }
void setLocalLibraryLocation(String localLibraryDir) { void setLocalLibraryLocation(List<String> localLibraryDirs) {
if (localLibraryDir.isEmpty) return; //if (localLibraryDir.isEmpty) return;
state = state.copyWith(localLibraryLocation: localLibraryDir); state = state.copyWith(localLibraryLocation: localLibraryDirs);
} }
void setLayoutMode(LayoutMode mode) { void setLayoutMode(LayoutMode mode) {

View File

@ -84,7 +84,7 @@ class UserPreferences with _$UserPreferences {
@Default(Market.US) Market recommendationMarket, @Default(Market.US) Market recommendationMarket,
@Default(SearchMode.youtube) SearchMode searchMode, @Default(SearchMode.youtube) SearchMode searchMode,
@Default("") String downloadLocation, @Default("") String downloadLocation,
@Default("") String localLibraryLocation, @Default([]) List<String> localLibraryLocation,
@Default("https://pipedapi.kavin.rocks") String pipedInstance, @Default("https://pipedapi.kavin.rocks") String pipedInstance,
@Default(ThemeMode.system) ThemeMode themeMode, @Default(ThemeMode.system) ThemeMode themeMode,
@Default(AudioSource.youtube) AudioSource audioSource, @Default(AudioSource.youtube) AudioSource audioSource,

View File

@ -43,7 +43,7 @@ mixin _$UserPreferences {
Market get recommendationMarket => throw _privateConstructorUsedError; Market get recommendationMarket => throw _privateConstructorUsedError;
SearchMode get searchMode => throw _privateConstructorUsedError; SearchMode get searchMode => throw _privateConstructorUsedError;
String get downloadLocation => throw _privateConstructorUsedError; String get downloadLocation => throw _privateConstructorUsedError;
String get localLibraryLocation => throw _privateConstructorUsedError; List<String> get localLibraryLocation => throw _privateConstructorUsedError;
String get pipedInstance => throw _privateConstructorUsedError; String get pipedInstance => throw _privateConstructorUsedError;
ThemeMode get themeMode => throw _privateConstructorUsedError; ThemeMode get themeMode => throw _privateConstructorUsedError;
AudioSource get audioSource => throw _privateConstructorUsedError; AudioSource get audioSource => throw _privateConstructorUsedError;
@ -89,7 +89,7 @@ abstract class $UserPreferencesCopyWith<$Res> {
Market recommendationMarket, Market recommendationMarket,
SearchMode searchMode, SearchMode searchMode,
String downloadLocation, String downloadLocation,
String localLibraryLocation, List<String> localLibraryLocation,
String pipedInstance, String pipedInstance,
ThemeMode themeMode, ThemeMode themeMode,
AudioSource audioSource, AudioSource audioSource,
@ -202,7 +202,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences>
localLibraryLocation: null == localLibraryLocation localLibraryLocation: null == localLibraryLocation
? _value.localLibraryLocation ? _value.localLibraryLocation
: localLibraryLocation // ignore: cast_nullable_to_non_nullable : localLibraryLocation // ignore: cast_nullable_to_non_nullable
as String, as List<String>,
pipedInstance: null == pipedInstance pipedInstance: null == pipedInstance
? _value.pipedInstance ? _value.pipedInstance
: pipedInstance // ignore: cast_nullable_to_non_nullable : pipedInstance // ignore: cast_nullable_to_non_nullable
@ -271,7 +271,7 @@ abstract class _$$UserPreferencesImplCopyWith<$Res>
Market recommendationMarket, Market recommendationMarket,
SearchMode searchMode, SearchMode searchMode,
String downloadLocation, String downloadLocation,
String localLibraryLocation, List<String> localLibraryLocation,
String pipedInstance, String pipedInstance,
ThemeMode themeMode, ThemeMode themeMode,
AudioSource audioSource, AudioSource audioSource,
@ -380,9 +380,9 @@ class __$$UserPreferencesImplCopyWithImpl<$Res>
: downloadLocation // ignore: cast_nullable_to_non_nullable : downloadLocation // ignore: cast_nullable_to_non_nullable
as String, as String,
localLibraryLocation: null == localLibraryLocation localLibraryLocation: null == localLibraryLocation
? _value.localLibraryLocation ? _value._localLibraryLocation
: localLibraryLocation // ignore: cast_nullable_to_non_nullable : localLibraryLocation // ignore: cast_nullable_to_non_nullable
as String, as List<String>,
pipedInstance: null == pipedInstance pipedInstance: null == pipedInstance
? _value.pipedInstance ? _value.pipedInstance
: pipedInstance // ignore: cast_nullable_to_non_nullable : pipedInstance // ignore: cast_nullable_to_non_nullable
@ -446,7 +446,7 @@ class _$UserPreferencesImpl implements _UserPreferences {
this.recommendationMarket = Market.US, this.recommendationMarket = Market.US,
this.searchMode = SearchMode.youtube, this.searchMode = SearchMode.youtube,
this.downloadLocation = "", this.downloadLocation = "",
this.localLibraryLocation = "", final List<String> localLibraryLocation = const [],
this.pipedInstance = "https://pipedapi.kavin.rocks", this.pipedInstance = "https://pipedapi.kavin.rocks",
this.themeMode = ThemeMode.system, this.themeMode = ThemeMode.system,
this.audioSource = AudioSource.youtube, this.audioSource = AudioSource.youtube,
@ -454,7 +454,8 @@ class _$UserPreferencesImpl implements _UserPreferences {
this.downloadMusicCodec = SourceCodecs.m4a, this.downloadMusicCodec = SourceCodecs.m4a,
this.discordPresence = true, this.discordPresence = true,
this.endlessPlayback = true, this.endlessPlayback = true,
this.enableConnect = false}); this.enableConnect = false})
: _localLibraryLocation = localLibraryLocation;
factory _$UserPreferencesImpl.fromJson(Map<String, dynamic> json) => factory _$UserPreferencesImpl.fromJson(Map<String, dynamic> json) =>
_$$UserPreferencesImplFromJson(json); _$$UserPreferencesImplFromJson(json);
@ -510,9 +511,16 @@ class _$UserPreferencesImpl implements _UserPreferences {
@override @override
@JsonKey() @JsonKey()
final String downloadLocation; final String downloadLocation;
final List<String> _localLibraryLocation;
@override @override
@JsonKey() @JsonKey()
final String localLibraryLocation; List<String> get localLibraryLocation {
if (_localLibraryLocation is EqualUnmodifiableListView)
return _localLibraryLocation;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_localLibraryLocation);
}
@override @override
@JsonKey() @JsonKey()
final String pipedInstance; final String pipedInstance;
@ -577,8 +585,8 @@ class _$UserPreferencesImpl implements _UserPreferences {
other.searchMode == searchMode) && other.searchMode == searchMode) &&
(identical(other.downloadLocation, downloadLocation) || (identical(other.downloadLocation, downloadLocation) ||
other.downloadLocation == downloadLocation) && other.downloadLocation == downloadLocation) &&
(identical(other.localLibraryLocation, localLibraryLocation) || const DeepCollectionEquality()
other.localLibraryLocation == localLibraryLocation) && .equals(other._localLibraryLocation, _localLibraryLocation) &&
(identical(other.pipedInstance, pipedInstance) || (identical(other.pipedInstance, pipedInstance) ||
other.pipedInstance == pipedInstance) && other.pipedInstance == pipedInstance) &&
(identical(other.themeMode, themeMode) || (identical(other.themeMode, themeMode) ||
@ -616,7 +624,7 @@ class _$UserPreferencesImpl implements _UserPreferences {
recommendationMarket, recommendationMarket,
searchMode, searchMode,
downloadLocation, downloadLocation,
localLibraryLocation, const DeepCollectionEquality().hash(_localLibraryLocation),
pipedInstance, pipedInstance,
themeMode, themeMode,
audioSource, audioSource,
@ -667,7 +675,7 @@ abstract class _UserPreferences implements UserPreferences {
final Market recommendationMarket, final Market recommendationMarket,
final SearchMode searchMode, final SearchMode searchMode,
final String downloadLocation, final String downloadLocation,
final String localLibraryLocation, final List<String> localLibraryLocation,
final String pipedInstance, final String pipedInstance,
final ThemeMode themeMode, final ThemeMode themeMode,
final AudioSource audioSource, final AudioSource audioSource,
@ -719,7 +727,7 @@ abstract class _UserPreferences implements UserPreferences {
@override @override
String get downloadLocation; String get downloadLocation;
@override @override
String get localLibraryLocation; List<String> get localLibraryLocation;
@override @override
String get pipedInstance; String get pipedInstance;
@override @override

View File

@ -44,7 +44,10 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson(
$enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ??
SearchMode.youtube, SearchMode.youtube,
downloadLocation: json['downloadLocation'] as String? ?? "", downloadLocation: json['downloadLocation'] as String? ?? "",
localLibraryLocation: json['localLibraryLocation'] as String? ?? "", localLibraryLocation: (json['localLibraryLocation'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
const [],
pipedInstance: pipedInstance:
json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks", json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks",
themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ??

View File

@ -1,89 +1,155 @@
{ {
"ar": [ "ar": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"bn": [ "bn": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"ca": [ "ca": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"cs": [ "cs": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"de": [ "de": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"es": [ "es": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"fa": [ "fa": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"fr": [ "fr": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"hi": [ "hi": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"it": [ "it": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"ja": [ "ja": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"ko": [ "ko": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"ne": [ "ne": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"nl": [ "nl": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"pl": [ "pl": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"pt": [ "pt": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"ru": [ "ru": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"th": [ "th": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"tr": [ "tr": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"uk": [ "uk": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"vi": [ "vi": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
], ],
"zh": [ "zh": [
"local_library_location" "local_library",
"local_library_description",
"add_library_location",
"remove_library_location"
] ]
} }