mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-09 16:57:31 +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 '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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user