mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-08 00:17:29 +00:00
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:
parent
20be5dbbcc
commit
b293ebbdec
@ -1,8 +1,11 @@
|
||||
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_desktop_tools/flutter_desktop_tools.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
@ -59,84 +62,87 @@ enum SortBy {
|
||||
album,
|
||||
}
|
||||
|
||||
final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
|
||||
final localTracksProvider = FutureProvider<Map<String, List<LocalTrack>>>((ref) async {
|
||||
try {
|
||||
if (kIsWeb) return [];
|
||||
if (kIsWeb) return {};
|
||||
final Map<String, List<LocalTrack>> 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),
|
||||
);
|
||||
for (final location in localLibraryLocations) {
|
||||
|
||||
for (var location in [downloadLocation, ...localLibraryLocations]) {
|
||||
if (location.isEmpty) continue;
|
||||
final entities = <FileSystemEntity>[];
|
||||
final dir = Directory(location);
|
||||
if (await Directory(location).exists()) {
|
||||
entities.addAll(Directory(location).listSync(recursive: true));
|
||||
}
|
||||
}
|
||||
|
||||
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 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,
|
||||
);
|
||||
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 {};
|
||||
}
|
||||
});
|
||||
|
||||
@ -171,7 +177,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
final playlist = ref.watch(proxyPlaylistProvider);
|
||||
final trackSnapshot = ref.watch(localTracksProvider);
|
||||
final isPlaylistPlaying =
|
||||
playlist.containsTracks(trackSnapshot.asData?.value ?? []);
|
||||
playlist.containsTracks(trackSnapshot.asData?.value.values.flattened.toList() ?? []);
|
||||
|
||||
final searchController = useTextEditingController();
|
||||
useValueListenable(searchController);
|
||||
@ -179,6 +185,31 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
final isFiltering = useState(false);
|
||||
|
||||
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(
|
||||
children: [
|
||||
@ -194,7 +225,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
if (!isPlaylistPlaying) {
|
||||
await playLocalTracks(
|
||||
ref,
|
||||
trackSnapshot.asData!.value,
|
||||
trackSnapshot.asData!.value.values.flattened.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -222,6 +253,16 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
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),
|
||||
FilledButton(
|
||||
child: const Icon(SpotubeIcons.refresh),
|
||||
@ -239,86 +280,116 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
},
|
||||
data: (groups) => Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
ref.invalidate(localTracksProvider);
|
||||
},
|
||||
child: Expanded(
|
||||
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,
|
||||
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(() {
|
||||
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(
|
||||
child: Skeletonizer(
|
||||
enabled: true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user