refactor: user local library

This commit is contained in:
Kingkor Roy Tirtho 2025-01-05 11:54:50 +06:00
parent 4afe0cca68
commit b8f2495acb
5 changed files with 441 additions and 439 deletions

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
@ -39,11 +39,8 @@ class ExpandableSearchField extends StatelessWidget {
child: TextField( child: TextField(
focusNode: searchFocus, focusNode: searchFocus,
controller: searchController, controller: searchController,
decoration: InputDecoration( placeholder: Text(context.l10n.search_tracks),
hintText: context.l10n.search_tracks, leading: const Icon(SpotubeIcons.search),
isDense: true,
prefixIcon: const Icon(SpotubeIcons.search),
),
), ),
), ),
), ),
@ -69,16 +66,9 @@ class ExpandableSearchButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return IconButton( return IconButton(
icon: icon, icon: icon,
style: IconButton.styleFrom( variance: isFiltering ? ButtonVariance.secondary : ButtonVariance.outline,
backgroundColor:
isFiltering ? theme.colorScheme.secondaryContainer : null,
foregroundColor: isFiltering ? theme.colorScheme.secondary : null,
minimumSize: const Size(25, 25),
),
onPressed: () { onPressed: () {
if (isFiltering) { if (isFiltering) {
searchFocus.requestFocus(); searchFocus.requestFocus();

View File

@ -215,7 +215,7 @@ class Spotube extends HookConsumerWidget {
theme: ThemeData( theme: ThemeData(
radius: .5, radius: .5,
iconTheme: const IconThemeProperties(), iconTheme: const IconThemeProperties(),
colorScheme: ColorSchemes.lightBlue(), colorScheme: ColorSchemes.lightOrange(),
surfaceOpacity: .8, surfaceOpacity: .8,
surfaceBlur: 10, surfaceBlur: 10,
), ),

View File

@ -1,18 +1,17 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/string.dart'; import 'package:spotube/extensions/string.dart';
import 'package:spotube/hooks/utils/use_brightness_value.dart';
import 'package:spotube/pages/library/local_folder.dart'; import 'package:spotube/pages/library/local_folder.dart';
import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
@ -26,8 +25,6 @@ class LocalFolderItem extends HookConsumerWidget {
final ThemeData(:colorScheme) = Theme.of(context); final ThemeData(:colorScheme) = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final lerpValue = useBrightnessValue(.9, .7);
final downloadFolder = final downloadFolder =
ref.watch(userPreferencesProvider.select((s) => s.downloadLocation)); ref.watch(userPreferencesProvider.select((s) => s.downloadLocation));
final cacheFolder = useFuture(UserPreferencesNotifier.getMusicCacheDir()); final cacheFolder = useFuture(UserPreferencesNotifier.getMusicCacheDir());
@ -60,8 +57,8 @@ class LocalFolderItem extends HookConsumerWidget {
final tracks = trackSnapshot.value ?? []; final tracks = trackSnapshot.value ?? [];
return InkWell( return Button(
onTap: () { onPressed: () {
context.goNamed( context.goNamed(
LocalLibraryPage.name, LocalLibraryPage.name,
queryParameters: { queryParameters: {
@ -71,18 +68,11 @@ class LocalFolderItem extends HookConsumerWidget {
extra: folder, extra: folder,
); );
}, },
borderRadius: BorderRadius.circular(8), style: ButtonVariance.card.copyWith(
child: Ink( padding: (context, states, value) {
decoration: BoxDecoration( return const EdgeInsets.all(8);
borderRadius: BorderRadius.circular(8), },
color: Color.lerp(
colorScheme.surfaceContainerHighest,
colorScheme.surface,
lerpValue,
), ),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -123,6 +113,9 @@ class LocalFolderItem extends HookConsumerWidget {
), ),
const Gap(8), const Gap(8),
Stack( Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Center( Center(
child: Text( child: Text(
@ -133,25 +126,47 @@ class LocalFolderItem extends HookConsumerWidget {
: basename(folder), : basename(folder),
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
), ),
if (!isDownloadFolder) Wrap(
spacing: 2,
runSpacing: 2,
children: [
for (final MapEntry(key: index, value: segment)
in segments.asMap().entries)
Text.rich(
TextSpan(
children: [
if (index != 0) const TextSpan(text: "/ "),
TextSpan(text: segment),
],
),
maxLines: 2,
).xSmall().muted(),
],
),
],
),
if (!isDownloadFolder && !isCacheFolder)
Align( Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,
child: PopupMenuButton( child: IconButton.ghost(
child: const Padding( icon: const Icon(Icons.more_vert),
padding: EdgeInsets.all(3), size: ButtonSize.small,
child: Icon(Icons.more_vert), onPressed: () {
), showDropdown(
itemBuilder: (context) { context: context,
return [ builder: (context) {
PopupMenuItem( return DropdownMenu(
child: ListTile( children: [
leading: const Icon(SpotubeIcons.folderRemove), MenuButton(
iconColor: colorScheme.error, leading: Icon(SpotubeIcons.folderRemove,
title: color: colorScheme.destructive),
child:
Text(context.l10n.remove_library_location), Text(context.l10n.remove_library_location),
onTap: () { onPressed: (context) {
final libraryLocations = ref final libraryLocations = ref
.read(userPreferencesProvider) .read(userPreferencesProvider)
.localLibraryLocation; .localLibraryLocation;
@ -163,44 +178,19 @@ class LocalFolderItem extends HookConsumerWidget {
.toList(), .toList(),
); );
}, },
),
) )
]; ],
);
},
);
}, },
), ),
), ),
], ],
), ),
const Spacer(), const Spacer(),
Wrap(
spacing: 2,
runSpacing: 2,
children: [
for (final MapEntry(key: index, value: segment)
in segments.asMap().entries)
Text.rich(
TextSpan(
children: [
if (index != 0)
TextSpan(
text: "/ ",
style: TextStyle(color: colorScheme.primary),
),
TextSpan(text: segment),
], ],
), ),
style: TextStyle(
fontSize: 10,
color: colorScheme.tertiary,
),
),
],
),
const Spacer(),
],
),
),
),
); );
} }
} }

View File

@ -1,9 +1,8 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart'; import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/library/local_folder/local_folder_item.dart'; import 'package:spotube/modules/library/local_folder/local_folder_item.dart';
@ -58,17 +57,23 @@ class UserLocalTracks extends HookConsumerWidget {
// For now, this gets all of them. // For now, this gets all of them.
ref.watch(localTracksProvider); ref.watch(localTracksProvider);
return LayoutBuilder(builder: (context, constrains) { final locations = [
return Padding( preferences.downloadLocation,
if (cacheDir.hasData) cacheDir.data!,
...preferences.localLibraryLocation,
];
return LayoutBuilder(
builder: (context, constrains) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0), padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column( child: Column(
children: [ children: [
Align( Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: TextButton.icon( child: Button.secondary(
icon: const Icon(SpotubeIcons.folderAdd), leading: const Icon(SpotubeIcons.folderAdd),
label: Text(context.l10n.add_library_location),
onPressed: addLocalLibraryLocation, onPressed: addLocalLibraryLocation,
child: Text(context.l10n.add_library_location),
), ),
), ),
const Gap(8), const Gap(8),
@ -84,23 +89,16 @@ class UserLocalTracks extends HookConsumerWidget {
crossAxisSpacing: 10, crossAxisSpacing: 10,
mainAxisSpacing: 10, mainAxisSpacing: 10,
), ),
itemCount: preferences.localLibraryLocation.length + itemCount: locations.length,
1 +
(cacheDir.hasData ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
return LocalFolderItem( return LocalFolderItem(
folder: index == 0 folder: locations[index],
? preferences.downloadLocation
: index == 1 && cacheDir.hasData
? cacheDir.data!
: preferences.localLibraryLocation[index - 1],
); );
}, },
), ),
), ),
], ],
), ),
); ));
});
} }
} }

View File

@ -3,13 +3,16 @@ import 'dart:math';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/button/back_button.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/string.dart'; import 'package:spotube/extensions/string.dart';
import 'package:spotube/modules/library/local_folder/cache_export_dialog.dart'; import 'package:spotube/modules/library/local_folder/cache_export_dialog.dart';
import 'package:spotube/modules/library/user_local_tracks.dart'; import 'package:spotube/modules/library/user_local_tracks.dart';
@ -65,7 +68,7 @@ class LocalLibraryPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context); final scale = context.theme.scaling;
final sortBy = useState<SortBy>(SortBy.none); final sortBy = useState<SortBy>(SortBy.none);
final playlist = ref.watch(audioPlayerProvider); final playlist = ref.watch(audioPlayerProvider);
@ -93,7 +96,13 @@ class LocalLibraryPage extends HookConsumerWidget {
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: Scaffold( child: Scaffold(
appBar: TitleBar( headers: [
TitleBar(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 0,
),
surfaceBlur: 0,
leading: const [BackButton()], leading: const [BackButton()],
title: Column( title: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -105,47 +114,43 @@ class LocalLibraryPage extends HookConsumerWidget {
: isCache : isCache
? context.l10n.cache_folder.capitalize() ? context.l10n.cache_folder.capitalize()
: location, : location,
style: textTheme.titleLarge,
), ),
FutureBuilder<String>( FutureBuilder<String>(
future: directorySize, future: directorySize,
builder: (context, snapshot) { builder: (context, snapshot) {
return Text( return Text(
"${(snapshot.data ?? 0)} GB", "${(snapshot.data ?? 0)} GB",
style: textTheme.labelSmall, ).xSmall().muted();
);
}, },
) )
], ],
), ),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
trailingGap: 10,
trailing: [ trailing: [
if (isCache) ...[ if (isCache) ...[
IconButton( IconButton.outline(
iconSize: 16, size: ButtonSize.small,
icon: Column( icon: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(SpotubeIcons.delete), const Icon(SpotubeIcons.delete),
Text( Text(context.l10n.clear_cache)
context.l10n.clear_cache,
style: textTheme.labelSmall,
)
], ],
), ).xSmall().iconSmall(),
onPressed: () async { onPressed: () async {
final accepted = await showDialog<bool>( final accepted = await showDialog<bool>(
context: context, context: context,
builder: (context) => AlertDialog.adaptive( builder: (context) => AlertDialog(
title: Text(context.l10n.clear_cache_confirmation), title: Text(context.l10n.clear_cache_confirmation),
actions: [ actions: [
TextButton( Button.outline(
onPressed: () { onPressed: () {
Navigator.of(context).pop(false); Navigator.of(context).pop(false);
}, },
child: Text(context.l10n.decline), child: Text(context.l10n.decline),
), ),
TextButton( Button.destructive(
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
}, },
@ -166,18 +171,17 @@ class LocalLibraryPage extends HookConsumerWidget {
} }
}, },
), ),
IconButton( IconButton.outline(
iconSize: 16, size: ButtonSize.small,
icon: Column( icon: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(SpotubeIcons.export), const Icon(SpotubeIcons.export),
Text( Text(
context.l10n.export, context.l10n.export,
style: textTheme.labelSmall,
) )
], ],
), ).xSmall().iconSmall(),
onPressed: () async { onPressed: () async {
final exportPath = final exportPath =
await FilePicker.platform.getDirectoryPath(); await FilePicker.platform.getDirectoryPath();
@ -207,54 +211,69 @@ class LocalLibraryPage extends HookConsumerWidget {
] ]
], ],
), ),
body: Column( ],
child: LayoutBuilder(
builder: (context, constraints) => Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Row( child: Row(
children: [ children: [
const SizedBox(width: 5), const Gap(5),
FilledButton( Button.primary(
onPressed: trackSnapshot.asData?.value != null onPressed: trackSnapshot.asData?.value != null
? () async { ? () async {
if (trackSnapshot.asData?.value.isNotEmpty == if (trackSnapshot
.asData?.value.isNotEmpty ==
true) { true) {
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
await playLocalTracks( await playLocalTracks(
ref, ref,
trackSnapshot.asData!.value[location] ?? [], trackSnapshot
.asData!.value[location] ??
[],
); );
} }
} }
} }
: null, : null,
child: Row( leading: Icon(
children: [
Text(context.l10n.play),
Icon(
isPlaylistPlaying isPlaylistPlaying
? SpotubeIcons.stop ? SpotubeIcons.stop
: SpotubeIcons.play, : SpotubeIcons.play,
)
],
), ),
child: Text(context.l10n.play),
), ),
const Spacer(), const Spacer(),
if (constraints.smAndDown)
ExpandableSearchButton( ExpandableSearchButton(
isFiltering: isFiltering.value, isFiltering: isFiltering.value,
onPressed: (value) => isFiltering.value = value, onPressed: (value) => isFiltering.value = value,
searchFocus: searchFocus, searchFocus: searchFocus,
)
else
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 300 * scale,
maxHeight: 38 * scale,
), ),
const SizedBox(width: 10), child: ExpandableSearchField(
isFiltering: true,
onChangeFiltering: (value) {},
searchController: searchController,
searchFocus: searchFocus,
),
),
const Gap(5),
SortTracksDropdown( SortTracksDropdown(
value: sortBy.value, value: sortBy.value,
onChanged: (value) { onChanged: (value) {
sortBy.value = value; sortBy.value = value;
}, },
), ),
const SizedBox(width: 5), const Gap(5),
FilledButton( IconButton.outline(
child: const Icon(SpotubeIcons.refresh), icon: const Icon(SpotubeIcons.refresh),
onPressed: () { onPressed: () {
ref.invalidate(localTracksProvider); ref.invalidate(localTracksProvider);
}, },
@ -268,11 +287,13 @@ class LocalLibraryPage extends HookConsumerWidget {
isFiltering: isFiltering.value, isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value, onChangeFiltering: (value) => isFiltering.value = value,
), ),
trackSnapshot.when( HookBuilder(builder: (context) {
return trackSnapshot.when(
data: (tracks) { data: (tracks) {
final sortedTracks = useMemoized(() { final sortedTracks = useMemoized(() {
return ServiceUtils.sortTracks( return ServiceUtils.sortTracks(
tracks[location] ?? <LocalTrack>[], sortBy.value); tracks[location] ?? <LocalTrack>[],
sortBy.value);
}, [sortBy.value, tracks]); }, [sortBy.value, tracks]);
final filteredTracks = useMemoized(() { final filteredTracks = useMemoized(() {
@ -297,7 +318,8 @@ class LocalLibraryPage extends HookConsumerWidget {
.toList(); .toList();
}, [searchController.text, sortedTracks]); }, [searchController.text, sortedTracks]);
if (!trackSnapshot.isLoading && filteredTracks.isEmpty) { if (!trackSnapshot.isLoading &&
filteredTracks.isEmpty) {
return const Expanded( return const Expanded(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -307,9 +329,9 @@ class LocalLibraryPage extends HookConsumerWidget {
} }
return Expanded( return Expanded(
child: RefreshIndicator( child: RefreshTrigger(
onRefresh: () async { onRefresh: () async {
ref.invalidate(localTracksProvider); // ref.invalidate(localTracksProvider);
}, },
child: InterScrollbar( child: InterScrollbar(
controller: controller, controller: controller,
@ -317,7 +339,8 @@ class LocalLibraryPage extends HookConsumerWidget {
enabled: trackSnapshot.isLoading, enabled: trackSnapshot.isLoading,
child: ListView.builder( child: ListView.builder(
controller: controller, controller: controller,
physics: const AlwaysScrollableScrollPhysics(), physics:
const AlwaysScrollableScrollPhysics(),
itemCount: trackSnapshot.isLoading itemCount: trackSnapshot.isLoading
? 5 ? 5
: filteredTracks.length, : filteredTracks.length,
@ -366,9 +389,10 @@ class LocalLibraryPage extends HookConsumerWidget {
), ),
error: (error, stackTrace) => error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()), Text(error.toString() + stackTrace.toString()),
) );
})
], ],
)), ))),
); );
} }
} }