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 <me@blakes.dev>
This commit is contained in:
Blake Leonard 2024-05-06 15:56:14 -04:00
parent 20be5dbbcc
commit b293ebbdec
No known key found for this signature in database
GPG Key ID: 3B1965C22D07D9F6

View File

@ -1,8 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'package:catcher_2/catcher_2.dart'; 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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
@ -59,29 +62,29 @@ enum SortBy {
album, album,
} }
final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async { final localTracksProvider = FutureProvider<Map<String, List<LocalTrack>>>((ref) async {
try { try {
if (kIsWeb) return []; if (kIsWeb) return {};
final Map<String, List<LocalTrack>> tracks = {};
final downloadLocation = ref.watch( final downloadLocation = ref.watch(
userPreferencesProvider.select((s) => s.downloadLocation), userPreferencesProvider.select((s) => s.downloadLocation),
); );
if (downloadLocation.isEmpty) return [];
final downloadDir = Directory(downloadLocation); final downloadDir = Directory(downloadLocation);
if (!await downloadDir.exists()) { if (!await downloadDir.exists()) {
await downloadDir.create(recursive: true); await downloadDir.create(recursive: true);
return [];
} }
final entities = downloadDir.listSync(recursive: true);
final localLibraryLocations = ref.watch( final localLibraryLocations = ref.watch(
userPreferencesProvider.select((s) => s.localLibraryLocation), userPreferencesProvider.select((s) => s.localLibraryLocation),
); );
for (final location in localLibraryLocations) {
for (var location in [downloadLocation, ...localLibraryLocations]) {
if (location.isEmpty) continue;
final entities = <FileSystemEntity>[];
final dir = Directory(location); final dir = Directory(location);
if (await Directory(location).exists()) { if (await Directory(location).exists()) {
entities.addAll(Directory(location).listSync(recursive: true)); entities.addAll(Directory(location).listSync(recursive: true));
} }
}
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) {
@ -120,7 +123,8 @@ final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
.where((e) => e.isNotEmpty) .where((e) => e.isNotEmpty)
.toList(); .toList();
final tracks = filesWithMetadata // ignore: no_leading_underscores_for_local_identifiers
final _tracks = filesWithMetadata
.map( .map(
(fileWithMetadata) => LocalTrack.fromTrack( (fileWithMetadata) => LocalTrack.fromTrack(
track: Track().fromFile( track: Track().fromFile(
@ -133,10 +137,12 @@ final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
) )
.toList(); .toList();
tracks[location] = _tracks;
}
return tracks; return tracks;
} catch (e, stack) { } catch (e, stack) {
Catcher2.reportCheckedError(e, stack); Catcher2.reportCheckedError(e, stack);
return []; return {};
} }
}); });
@ -171,7 +177,7 @@ class UserLocalTracks extends HookConsumerWidget {
final playlist = ref.watch(proxyPlaylistProvider); final playlist = ref.watch(proxyPlaylistProvider);
final trackSnapshot = ref.watch(localTracksProvider); final trackSnapshot = ref.watch(localTracksProvider);
final isPlaylistPlaying = final isPlaylistPlaying =
playlist.containsTracks(trackSnapshot.asData?.value ?? []); playlist.containsTracks(trackSnapshot.asData?.value.values.flattened.toList() ?? []);
final searchController = useTextEditingController(); final searchController = useTextEditingController();
useValueListenable(searchController); useValueListenable(searchController);
@ -179,6 +185,31 @@ class UserLocalTracks extends HookConsumerWidget {
final isFiltering = useState(false); final isFiltering = useState(false);
final controller = useScrollController(); final controller = useScrollController();
final preferencesNotifier = ref.watch(userPreferencesProvider.notifier);
final preferences = ref.watch(userPreferencesProvider);
final addLocalLibraryLocation = useCallback(() async {
if (DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS) {
final dirStr = await FilePicker.platform.getDirectoryPath(
initialDirectory: preferences.downloadLocation,
);
if (dirStr == null) return;
if (preferences.localLibraryLocation.contains(dirStr)) return;
preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]);
} else {
String? dirStr = await getDirectoryPath(
initialDirectory: preferences.downloadLocation,
);
if (dirStr == null) return;
if (preferences.localLibraryLocation.contains(dirStr)) return;
preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]);
}
}, [preferences.localLibraryLocation]);
final removeLocalLibraryLocation = useCallback((String location) {
if (!preferences.localLibraryLocation.contains(location)) return;
preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation]..remove(location));
}, [preferences.localLibraryLocation]);
return Column( return Column(
children: [ children: [
@ -194,7 +225,7 @@ class UserLocalTracks extends HookConsumerWidget {
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
await playLocalTracks( await playLocalTracks(
ref, ref,
trackSnapshot.asData!.value, trackSnapshot.asData!.value.values.flattened.toList(),
); );
} }
} }
@ -222,6 +253,16 @@ class UserLocalTracks extends HookConsumerWidget {
sortBy.value = value; sortBy.value = value;
}, },
), ),
if (!kIsWeb) ...[
const SizedBox(width: 10),
Tooltip(
message: context.l10n.add_library_location,
child: IconButton(
onPressed: addLocalLibraryLocation,
icon: const Icon(SpotubeIcons.folder), // TODO: use a "folder add" icon
),
),
],
const SizedBox(width: 5), const SizedBox(width: 5),
FilledButton( FilledButton(
child: const Icon(SpotubeIcons.refresh), child: const Icon(SpotubeIcons.refresh),
@ -239,7 +280,42 @@ class UserLocalTracks extends HookConsumerWidget {
onChangeFiltering: (value) => isFiltering.value = value, onChangeFiltering: (value) => isFiltering.value = value,
), ),
trackSnapshot.when( trackSnapshot.when(
data: (tracks) { data: (groups) => Expanded(
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(localTracksProvider);
},
child: Expanded(
child: InterScrollbar(
controller: controller,
child: CustomScrollView(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
for (var MapEntry(key: location, value: tracks) in groups.entries) ...[
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
sliver: SliverToBoxAdapter(
child: Row(
children: [
Text(preferences.downloadLocation == location ? context.l10n.downloads : location,
style: Theme.of(context).textTheme.titleLarge
),
const Expanded(child: SizedBox()),
Tooltip(
message: context.l10n.remove_library_location,
child: IconButton(
icon: Icon(SpotubeIcons.trash, color: Colors.red[400]),
onPressed: () => removeLocalLibraryLocation(location),
),
),
],
),
),
),
HookBuilder(
key: ValueKey("LocalTracks\$$location"),
builder: (context) {
final sortedTracks = useMemoized(() { final sortedTracks = useMemoized(() {
return ServiceUtils.sortTracks(tracks, sortBy.value); return ServiceUtils.sortTracks(tracks, sortBy.value);
}, [sortBy.value, tracks]); }, [sortBy.value, tracks]);
@ -267,26 +343,16 @@ class UserLocalTracks extends HookConsumerWidget {
}, [searchController.text, sortedTracks]); }, [searchController.text, sortedTracks]);
if (!trackSnapshot.isLoading && filteredTracks.isEmpty) { if (!trackSnapshot.isLoading && filteredTracks.isEmpty) {
return const Expanded( return const SliverFillRemaining(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [NotFound()], children: [NotFound()],
), ),
); );
} }
return SliverSkeletonizer(
return Expanded(
child: RefreshIndicator(
onRefresh: () async {
ref.invalidate(localTracksProvider);
},
child: InterScrollbar(
controller: controller,
child: Skeletonizer(
enabled: trackSnapshot.isLoading, enabled: trackSnapshot.isLoading,
child: ListView.builder( child: SliverList.builder(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: itemCount:
trackSnapshot.isLoading ? 5 : filteredTracks.length, trackSnapshot.isLoading ? 5 : filteredTracks.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -314,11 +380,16 @@ class UserLocalTracks extends HookConsumerWidget {
); );
}, },
), ),
),
),
),
); );
}, }
)
]
]
)
),
),
),
),
loading: () => Expanded( loading: () => Expanded(
child: Skeletonizer( child: Skeletonizer(
enabled: true, enabled: true,