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,84 +62,87 @@ 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) {
final mimetype = lookupMimeType(file.path); final mimetype = lookupMimeType(file.path);
return mimetype != null && supportedAudioTypes.contains(mimetype); return mimetype != null && supportedAudioTypes.contains(mimetype);
}).map( }).map(
(file) async { (file) async {
try { try {
final metadata = await MetadataGod.readMetadata(file: file.path); final metadata = await MetadataGod.readMetadata(file: file.path);
final imageFile = File(join( final imageFile = File(join(
(await getTemporaryDirectory()).path, (await getTemporaryDirectory()).path,
"spotube", "spotube",
basenameWithoutExtension(file.path) + basenameWithoutExtension(file.path) +
imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!,
)); ));
if (!await imageFile.exists() && metadata.picture != null) { if (!await imageFile.exists() && metadata.picture != null) {
await imageFile.create(recursive: true); await imageFile.create(recursive: true);
await imageFile.writeAsBytes( await imageFile.writeAsBytes(
metadata.picture?.data ?? [], metadata.picture?.data ?? [],
mode: FileMode.writeOnly, 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}; // ignore: no_leading_underscores_for_local_identifiers
} catch (e, stack) { final _tracks = filesWithMetadata
if (e is FfiException) { .map(
return {"file": file}; (fileWithMetadata) => LocalTrack.fromTrack(
} track: Track().fromFile(
Catcher2.reportCheckedError(e, stack); fileWithMetadata["file"],
return {}; metadata: fileWithMetadata["metadata"],
} art: fileWithMetadata["art"],
}, ),
), path: fileWithMetadata["file"].path,
))
.where((e) => e.isNotEmpty)
.toList();
final tracks = filesWithMetadata
.map(
(fileWithMetadata) => LocalTrack.fromTrack(
track: Track().fromFile(
fileWithMetadata["file"],
metadata: fileWithMetadata["metadata"],
art: fileWithMetadata["art"],
), ),
path: fileWithMetadata["file"].path, )
), .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,86 +280,116 @@ class UserLocalTracks extends HookConsumerWidget {
onChangeFiltering: (value) => isFiltering.value = value, onChangeFiltering: (value) => isFiltering.value = value,
), ),
trackSnapshot.when( trackSnapshot.when(
data: (tracks) { data: (groups) => Expanded(
final sortedTracks = useMemoized(() { child: RefreshIndicator(
return ServiceUtils.sortTracks(tracks, sortBy.value); onRefresh: () async {
}, [sortBy.value, tracks]); ref.invalidate(localTracksProvider);
},
final filteredTracks = useMemoized(() { child: Expanded(
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( child: InterScrollbar(
controller: controller, controller: controller,
child: Skeletonizer( child: CustomScrollView(
enabled: trackSnapshot.isLoading, controller: controller,
child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(),
controller: controller, slivers: [
physics: const AlwaysScrollableScrollPhysics(), for (var MapEntry(key: location, value: tracks) in groups.entries) ...[
itemCount: SliverPadding(
trackSnapshot.isLoading ? 5 : filteredTracks.length, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemBuilder: (context, index) { sliver: SliverToBoxAdapter(
if (trackSnapshot.isLoading) { child: Row(
return TrackTile( children: [
playlist: playlist, Text(preferences.downloadLocation == location ? context.l10n.downloads : location,
track: FakeData.track, style: Theme.of(context).textTheme.titleLarge
index: index, ),
); const Expanded(child: SizedBox()),
} Tooltip(
message: context.l10n.remove_library_location,
final track = filteredTracks[index]; child: IconButton(
return TrackTile( icon: Icon(SpotubeIcons.trash, color: Colors.red[400]),
index: index, onPressed: () => removeLocalLibraryLocation(location),
playlist: playlist, ),
track: track, ),
userPlaylist: false, ],
onTap: () async { ),
await playLocalTracks( ),
ref, ),
sortedTracks, HookBuilder(
currentTrack: track, key: ValueKey("LocalTracks\$$location"),
builder: (context) {
final sortedTracks = useMemoized(() {
return ServiceUtils.sortTracks(tracks, 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 SliverFillRemaining(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [NotFound()],
),
);
}
return SliverSkeletonizer(
enabled: trackSnapshot.isLoading,
child: SliverList.builder(
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( loading: () => Expanded(
child: Skeletonizer( child: Skeletonizer(
enabled: true, enabled: true,