From d53782da23fb4ce44c1337b141d1c575fb739e30 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 28 Dec 2024 14:30:25 +0600 Subject: [PATCH] refactor: playlist and album pages --- .fvm/fvm_config.json | 2 +- .fvmrc | 2 +- .vscode/settings.json | 2 +- .../adaptive/adaptive_pop_sheet_list.dart | 31 +- lib/components/heart_button/heart_button.dart | 50 +-- lib/components/sort_tracks_dropdown.dart | 88 ----- .../presentation_actions.dart | 220 +++++++++++ .../track_presentation/presentation_list.dart | 84 +++++ .../presentation_modifiers.dart | 117 ++++++ .../presentation_props.dart} | 67 ++-- .../presentation_state.dart | 157 ++++++++ .../track_presentation/presentation_top.dart | 262 +++++++++++++ .../sort_tracks_dropdown.dart | 70 ++++ .../track_presentation.dart | 72 ++++ .../use_action_callbacks.dart | 135 +++++++ .../use_is_user_playlist.dart | 0 .../use_track_tile_play_callback.dart | 84 +++++ lib/components/track_tile/track_tile.dart | 354 ++++++++++-------- .../sections/body/track_view_body.dart | 192 ---------- .../body/track_view_body_headers.dart | 105 ------ .../sections/body/track_view_options.dart | 140 ------- .../sections/header/flexible_header.dart | 167 --------- .../sections/header/header_actions.dart | 111 ------ .../sections/header/header_buttons.dart | 206 ---------- lib/components/tracks_view/track_view.dart | 52 --- .../tracks_view/track_view_provider.dart | 64 ---- lib/l10n/app_en.arb | 7 +- lib/main.dart | 4 + lib/modules/root/sidebar.dart | 4 +- .../settings/section_card_with_heading.dart | 6 +- lib/pages/album/album.dart | 80 ++-- lib/pages/library/local_folder.dart | 2 +- lib/pages/playlist/liked_playlist.dart | 48 +-- lib/pages/playlist/playlist.dart | 93 ++--- .../xcshareddata/xcschemes/Runner.xcscheme | 1 + pubspec.lock | 48 +-- untranslated_messages.json | 182 +++++++-- 37 files changed, 1793 insertions(+), 1516 deletions(-) delete mode 100644 lib/components/sort_tracks_dropdown.dart create mode 100644 lib/components/track_presentation/presentation_actions.dart create mode 100644 lib/components/track_presentation/presentation_list.dart create mode 100644 lib/components/track_presentation/presentation_modifiers.dart rename lib/components/{tracks_view/track_view_props.dart => track_presentation/presentation_props.dart} (60%) create mode 100644 lib/components/track_presentation/presentation_state.dart create mode 100644 lib/components/track_presentation/presentation_top.dart create mode 100644 lib/components/track_presentation/sort_tracks_dropdown.dart create mode 100644 lib/components/track_presentation/track_presentation.dart create mode 100644 lib/components/track_presentation/use_action_callbacks.dart rename lib/components/{tracks_view/sections/body => track_presentation}/use_is_user_playlist.dart (100%) create mode 100644 lib/components/track_presentation/use_track_tile_play_callback.dart delete mode 100644 lib/components/tracks_view/sections/body/track_view_body.dart delete mode 100644 lib/components/tracks_view/sections/body/track_view_body_headers.dart delete mode 100644 lib/components/tracks_view/sections/body/track_view_options.dart delete mode 100644 lib/components/tracks_view/sections/header/flexible_header.dart delete mode 100644 lib/components/tracks_view/sections/header/header_actions.dart delete mode 100644 lib/components/tracks_view/sections/header/header_buttons.dart delete mode 100644 lib/components/tracks_view/track_view.dart delete mode 100644 lib/components/tracks_view/track_view_provider.dart diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 41b45a53..7572d05e 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,3 +1,3 @@ { - "flutterSdkVersion": "3.27.1" + "flutterSdkVersion": "3.28.0-0.1.pre" } \ No newline at end of file diff --git a/.fvmrc b/.fvmrc index d1af5d57..089fa312 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,4 +1,4 @@ { - "flutter": "3.27.1", + "flutter": "3.28.0-0.1.pre", "flavors": {} } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d76417f..a5548411 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -28,5 +28,5 @@ "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", "*.dart": "${capture}.g.dart,${capture}.freezed.dart" }, - "dart.flutterSdkPath": ".fvm/versions/3.27.1" + "dart.flutterSdkPath": ".fvm/versions/3.28.0-0.1.pre" } \ No newline at end of file diff --git a/lib/components/adaptive/adaptive_pop_sheet_list.dart b/lib/components/adaptive/adaptive_pop_sheet_list.dart index 63499e8a..d81ca977 100644 --- a/lib/components/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/adaptive/adaptive_pop_sheet_list.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' show ListTile, showModalBottomSheet; +import 'package:flutter/material.dart' show showModalBottomSheet; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -39,6 +39,8 @@ class AdaptivePopSheetList extends StatelessWidget { final Offset offset; + final ButtonVariance variance; + const AdaptivePopSheetList({ super.key, required this.children, @@ -49,6 +51,7 @@ class AdaptivePopSheetList extends StatelessWidget { this.onSelected, required this.tooltip, this.offset = Offset.zero, + this.variance = ButtonVariance.ghost, }) : assert( !(icon != null && child != null), 'Either icon or child must be provided', @@ -79,7 +82,7 @@ class AdaptivePopSheetList extends StatelessWidget { }).toList(); if (mediaQuery.mdAndUp) { - await showDropdown( + await showDropdown( context: context, rootOverlay: useRootNavigator, // heightConstraint: PopoverConstraint.anchorFixedSize, @@ -113,19 +116,21 @@ class AdaptivePopSheetList extends StatelessWidget { itemBuilder: (context, index) { final data = childrenModified[index]; - return ListTile( - dense: true, - leading: data.leading, - title: data.child, + return Button( enabled: data.enabled, - trailing: data.trailing, - focusNode: data.focusNode, - onTap: () { + style: ButtonVariance.ghost.copyWith( + padding: (context, state, value) => const EdgeInsets.all(16), + ), + onPressed: () { data.onPressed?.call(context); if (data.autoClose) { Navigator.of(context).pop(); } }, + leading: data.leading, + trailing: data.trailing, + alignment: Alignment.centerLeft, + child: data.child, ); }, ); @@ -142,7 +147,8 @@ class AdaptivePopSheetList extends StatelessWidget { tooltip: TooltipContainer( child: Text(tooltip), ), - child: IconButton.ghost( + child: IconButton( + variance: variance, icon: icon ?? const Icon(SpotubeIcons.moreVertical), onPressed: () { final renderBox = context.findRenderObject() as RenderBox; @@ -167,7 +173,7 @@ class AdaptivePopSheetList extends StatelessWidget { tooltip: TooltipContainer(child: Text(tooltip)), child: Button( onPressed: () => showDropdownMenu(context, Offset.zero), - style: const ButtonStyle.ghost(), + style: variance, child: IgnorePointer(child: child), ), ); @@ -175,7 +181,8 @@ class AdaptivePopSheetList extends StatelessWidget { return Tooltip( tooltip: TooltipContainer(child: Text(tooltip)), - child: IconButton.ghost( + child: IconButton( + variance: variance, icon: icon ?? const Icon(SpotubeIcons.moreVertical), onPressed: () => showDropdownMenu(context, Offset.zero), ), diff --git a/lib/components/heart_button/heart_button.dart b/lib/components/heart_button/heart_button.dart index fa4318cc..56cb22ab 100644 --- a/lib/components/heart_button/heart_button.dart +++ b/lib/components/heart_button/heart_button.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/heart_button/use_track_toggle_like.dart'; @@ -13,12 +13,16 @@ class HeartButton extends HookConsumerWidget { final IconData? icon; final Color? color; final String? tooltip; + final ButtonVariance variance; + final ButtonSize size; const HeartButton({ required this.isLiked, required this.onPressed, this.color, this.tooltip, this.icon, + this.variance = ButtonVariance.ghost, + this.size = ButtonSize.normal, super.key, }); @@ -28,28 +32,32 @@ class HeartButton extends HookConsumerWidget { if (auth.asData?.value == null) return const SizedBox.shrink(); - return IconButton( - tooltip: tooltip, - icon: AnimatedSwitcher( - switchInCurve: Curves.fastOutSlowIn, - switchOutCurve: Curves.fastOutSlowIn, - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) { - return ScaleTransition( - scale: animation, - child: child, - ); - }, - child: Icon( - icon ?? - (isLiked - ? Icons.favorite_rounded - : Icons.favorite_outline_rounded), - key: ValueKey(isLiked), - color: color ?? (isLiked ? color ?? Colors.red : null), + return Tooltip( + tooltip: TooltipContainer(child: Text(tooltip ?? "")), + child: IconButton( + variance: variance, + size: size, + icon: AnimatedSwitcher( + switchInCurve: Curves.fastOutSlowIn, + switchOutCurve: Curves.fastOutSlowIn, + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + child: Icon( + icon ?? + (isLiked + ? Icons.favorite_rounded + : Icons.favorite_outline_rounded), + key: ValueKey(isLiked), + color: color ?? (isLiked ? color ?? Colors.red : null), + ), ), + onPressed: onPressed, ), - onPressed: onPressed, ); } } diff --git a/lib/components/sort_tracks_dropdown.dart b/lib/components/sort_tracks_dropdown.dart deleted file mode 100644 index 4f65e738..00000000 --- a/lib/components/sort_tracks_dropdown.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/library/user_local_tracks.dart'; -import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/extensions/context.dart'; - -class SortTracksDropdown extends StatelessWidget { - final SortBy? value; - final void Function(SortBy)? onChanged; - const SortTracksDropdown({ - this.onChanged, - this.value, - super.key, - }); - - @override - Widget build(BuildContext context) { - var theme = Theme.of(context); - return ListTileTheme( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - child: AdaptivePopSheetList( - children: [ - AdaptiveMenuButton( - value: SortBy.none, - enabled: value != SortBy.none, - child: Text(context.l10n.none), - ), - AdaptiveMenuButton( - value: SortBy.ascending, - enabled: value != SortBy.ascending, - child: Text(context.l10n.sort_a_z), - ), - AdaptiveMenuButton( - value: SortBy.descending, - enabled: value != SortBy.descending, - child: Text(context.l10n.sort_z_a), - ), - AdaptiveMenuButton( - value: SortBy.newest, - enabled: value != SortBy.newest, - child: Text(context.l10n.sort_newest), - ), - AdaptiveMenuButton( - value: SortBy.oldest, - enabled: value != SortBy.oldest, - child: Text(context.l10n.sort_oldest), - ), - AdaptiveMenuButton( - value: SortBy.duration, - enabled: value != SortBy.duration, - child: Text(context.l10n.sort_duration), - ), - AdaptiveMenuButton( - value: SortBy.artist, - enabled: value != SortBy.artist, - child: Text(context.l10n.sort_artist), - ), - AdaptiveMenuButton( - value: SortBy.album, - enabled: value != SortBy.album, - child: Text(context.l10n.sort_album), - ), - ], - headings: [ - Text(context.l10n.sort_tracks), - ], - onSelected: onChanged, - tooltip: context.l10n.sort_tracks, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - child: DefaultTextStyle( - style: theme.textTheme.titleSmall!, - child: Row( - children: [ - const Icon(SpotubeIcons.sort), - const SizedBox(width: 8), - Text(context.l10n.sort_tracks), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/track_presentation/presentation_actions.dart b/lib/components/track_presentation/presentation_actions.dart new file mode 100644 index 00000000..41f518d0 --- /dev/null +++ b/lib/components/track_presentation/presentation_actions.dart @@ -0,0 +1,220 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/components/dialogs/confirm_download_dialog.dart'; +import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/presentation_state.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class TrackPresentationActionsSection extends HookConsumerWidget { + const TrackPresentationActionsSection({super.key}); + + showToastForAction(BuildContext context, String action, int count) { + final message = switch (action) { + "download" => (context.l10n.download_count(count), SpotubeIcons.download), + "add-to-playlist" => ( + context.l10n.add_count_to_playlist(count), + SpotubeIcons.playlistAdd + ), + "add-to-queue" => ( + context.l10n.add_count_to_queue(count), + SpotubeIcons.queueAdd + ), + "play-next" => ( + context.l10n.play_count_next(count), + SpotubeIcons.lightning + ), + _ => ("", SpotubeIcons.error), + }; + + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + leading: Icon(message.$2), + title: Text(message.$1), + leadingAlignment: Alignment.center, + trailing: IconButton.ghost( + size: ButtonSize.small, + icon: const Icon(SpotubeIcons.close), + onPressed: () { + overlay.close(); + }, + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context, ref) { + final options = TrackPresentationOptions.of(context); + + ref.watch(downloadManagerProvider); + final downloader = ref.watch(downloadManagerProvider.notifier); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); + final audioSource = + ref.watch(userPreferencesProvider.select((s) => s.audioSource)); + + final state = ref.watch(presentationStateProvider(options.collection)); + final notifier = + ref.watch(presentationStateProvider(options.collection).notifier); + final selectedTracks = state.selectedTracks; + + return AdaptivePopSheetList( + tooltip: context.l10n.more_actions, + headings: [ + Text( + context.l10n.more_actions, + style: context.theme.typography.large, + ), + ], + onSelected: (action) async { + var tracks = selectedTracks; + + if (selectedTracks.isEmpty) { + tracks = await options.pagination.onFetchAll(); + + notifier.selectAllTracks(); + } + + if (!context.mounted) return; + + switch (action) { + case "download": + { + final confirmed = audioSource == AudioSource.piped || + await showDialog( + context: context, + builder: (context) { + return const ConfirmDownloadDialog(); + }, + ); + if (confirmed != true) return; + downloader.batchAddToQueue(tracks); + notifier.deselectAllTracks(); + if (!context.mounted) return; + showToastForAction(context, action, tracks.length); + break; + } + case "add-to-playlist": + { + if (context.mounted) { + final worked = await showDialog( + context: context, + builder: (context) { + return PlaylistAddTrackDialog( + openFromPlaylist: options.collectionId, + tracks: tracks.toList(), + ); + }, + ); + + if (!context.mounted || worked != true) return; + showToastForAction(context, action, tracks.length); + } + break; + } + case "play-next": + { + playlistNotifier.addTracksAtFirst(tracks); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([options.collection as PlaylistSimple]); + } + notifier.deselectAllTracks(); + if (!context.mounted) return; + showToastForAction(context, action, tracks.length); + break; + } + case "add-to-queue": + { + playlistNotifier.addTracks(tracks); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([options.collection as PlaylistSimple]); + } + notifier.deselectAllTracks(); + if (!context.mounted) return; + showToastForAction(context, action, tracks.length); + break; + } + default: + } + + if (!context.mounted) return; + }, + icon: const Icon(SpotubeIcons.moreVertical), + variance: ButtonVariance.outline, + children: [ + AdaptiveMenuButton( + value: "download", + leading: const Icon(SpotubeIcons.download), + child: selectedTracks.isEmpty || + selectedTracks.length == options.tracks.length + ? Text( + context.l10n.download_all, + ) + : Text( + context.l10n.download_count(selectedTracks.length), + ), + ), + AdaptiveMenuButton( + value: "add-to-playlist", + leading: const Icon(SpotubeIcons.playlistAdd), + child: selectedTracks.isEmpty || + selectedTracks.length == options.tracks.length + ? Text( + context.l10n.add_all_to_playlist, + ) + : Text( + context.l10n.add_count_to_playlist(selectedTracks.length), + ), + ), + AdaptiveMenuButton( + value: "add-to-queue", + leading: const Icon(SpotubeIcons.queueAdd), + child: selectedTracks.isEmpty || + selectedTracks.length == options.tracks.length + ? Text( + context.l10n.add_all_to_queue, + ) + : Text( + context.l10n.add_count_to_queue(selectedTracks.length), + ), + ), + AdaptiveMenuButton( + value: "play-next", + leading: const Icon(SpotubeIcons.lightning), + child: selectedTracks.isEmpty || + selectedTracks.length == options.tracks.length + ? Text( + context.l10n.play_all_next, + ) + : Text( + context.l10n.play_count_next(selectedTracks.length), + ), + ), + ], + ); + } +} diff --git a/lib/components/track_presentation/presentation_list.dart b/lib/components/track_presentation/presentation_list.dart new file mode 100644 index 00000000..55b4c46d --- /dev/null +++ b/lib/components/track_presentation/presentation_list.dart @@ -0,0 +1,84 @@ +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/presentation_state.dart'; +import 'package:spotube/components/track_presentation/use_track_tile_play_callback.dart'; +import 'package:spotube/components/track_tile/track_tile.dart'; +import 'package:spotube/components/track_presentation/use_is_user_playlist.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +class PresentationListSection extends HookConsumerWidget { + const PresentationListSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final options = TrackPresentationOptions.of(context); + final playlist = ref.watch(audioPlayerProvider); + final state = ref.watch(presentationStateProvider(options.collection)); + final notifier = + ref.read(presentationStateProvider(options.collection).notifier); + final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId); + + final onTileTap = useTrackTilePlayCallback(ref); + + return SliverInfiniteList( + isLoading: options.pagination.isLoading, + onFetchData: options.pagination.onFetchMore, + itemCount: state.presentationTracks.length, + hasReachedMax: !options.pagination.hasNextPage, + loadingBuilder: (context) { + return Skeletonizer( + enabled: true, + child: TrackTile( + index: 0, + playlist: playlist, + track: FakeData.track, + ), + ); + }, + emptyBuilder: (context) => Skeletonizer( + enabled: true, + child: Column( + children: List.generate( + 10, + (index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), + ), + ), + ), + itemBuilder: (context, index) { + final track = state.presentationTracks[index]; + final isSelected = state.selectedTracks.any((e) => e.id == track.id); + return TrackTile( + userPlaylist: isUserPlaylist, + playlistId: options.collectionId, + index: index, + playlist: playlist, + track: track, + selected: isSelected, + onTap: () => onTileTap(track, index), + onChanged: state.selectedTracks.isEmpty + ? null + : (isSelected) { + if (isSelected == true) { + notifier.selectTrack(track); + } else { + notifier.deselectTrack(track); + } + }, + onLongPress: () { + notifier.selectTrack(track); + HapticFeedback.selectionClick(); + }, + ); + }, + ); + } +} diff --git a/lib/components/track_presentation/presentation_modifiers.dart b/lib/components/track_presentation/presentation_modifiers.dart new file mode 100644 index 00000000..d1678e17 --- /dev/null +++ b/lib/components/track_presentation/presentation_modifiers.dart @@ -0,0 +1,117 @@ +import 'package:flutter_hooks/flutter_hooks.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:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/track_presentation/sort_tracks_dropdown.dart'; +import 'package:spotube/components/track_presentation/presentation_actions.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/presentation_state.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; + +class TrackPresentationModifiersSection extends HookConsumerWidget { + const TrackPresentationModifiersSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final options = TrackPresentationOptions.of(context); + final state = ref.watch(presentationStateProvider(options.collection)); + final notifier = ref.watch( + presentationStateProvider(options.collection).notifier, + ); + + final controller = useTextEditingController(); + + return LayoutBuilder(builder: (context, constrains) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: constrains.mdAndUp ? 16 : 8, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + state: state.selectedTracks.length == options.tracks.length + ? CheckboxState.checked + : CheckboxState.unchecked, + onChanged: (value) { + if (value == CheckboxState.checked) { + notifier.selectAllTracks(); + } else { + notifier.deselectAllTracks(); + } + }, + ), + ], + ), + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 320, + ), + child: TextField( + controller: controller, + leading: Icon( + SpotubeIcons.search, + color: context.theme.colorScheme.mutedForeground, + ), + placeholder: Text(context.l10n.search_tracks), + onChanged: (value) { + if (value.isEmpty) { + notifier.clearFilter(); + } else { + notifier.filterTracks(value); + } + }, + trailing: ListenableBuilder( + listenable: controller, + builder: (context, _) { + return AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: controller.text.isEmpty + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: + const SizedBox.square(dimension: 20), + secondChild: AnimatedScale( + duration: const Duration(milliseconds: 300), + scale: controller.text.isEmpty ? 0 : 1, + child: IconButton.ghost( + size: const ButtonSize(.6), + icon: const Icon(SpotubeIcons.close), + onPressed: () { + controller.clear(); + notifier.clearFilter(); + }, + ), + ), + ); + }), + ), + ), + ), + SortTracksDropdown( + value: state.sortBy, + onChanged: (value) { + notifier.sortTracks(value); + }, + ), + const TrackPresentationActionsSection(), + ], + ), + ), + ], + ), + ); + }); + } +} diff --git a/lib/components/tracks_view/track_view_props.dart b/lib/components/track_presentation/presentation_props.dart similarity index 60% rename from lib/components/tracks_view/track_view_props.dart rename to lib/components/track_presentation/presentation_props.dart index b0a00ae2..144cf0e8 100644 --- a/lib/components/tracks_view/track_view_props.dart +++ b/lib/components/track_presentation/presentation_props.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:flutter/material.dart' hide Page; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; class PaginationProps { @@ -38,31 +38,33 @@ class PaginationProps { onRefresh.hashCode; } -class InheritedTrackView extends InheritedWidget { +class TrackPresentationOptions { final Object collection; final String title; final String? description; + final String? owner; + final String? ownerImage; final String image; final String routePath; final List tracks; final PaginationProps pagination; final bool isLiked; - final String shareUrl; + final String? shareUrl; // events final FutureOr Function()? onHeart; // if null heart button will hidden - const InheritedTrackView({ - super.key, - required super.child, + const TrackPresentationOptions({ required this.collection, required this.title, this.description, + this.owner, + this.ownerImage, required this.image, required this.tracks, required this.pagination, required this.routePath, - required this.shareUrl, + this.shareUrl, this.isLiked = false, this.onHeart, }) : assert(collection is AlbumSimple || collection is PlaylistSimple); @@ -71,29 +73,36 @@ class InheritedTrackView extends InheritedWidget { ? (collection as AlbumSimple).id! : (collection as PlaylistSimple).id!; - @override - bool updateShouldNotify(InheritedTrackView oldWidget) { - return oldWidget.title != title || - oldWidget.description != description || - oldWidget.image != image || - oldWidget.tracks != tracks || - oldWidget.pagination != pagination || - oldWidget.isLiked != isLiked || - oldWidget.onHeart != onHeart || - oldWidget.shareUrl != shareUrl || - oldWidget.routePath != routePath || - oldWidget.collection != collection || - oldWidget.child != child; + static TrackPresentationOptions of(BuildContext context) { + return Data.of(context); } - static InheritedTrackView of(BuildContext context) { - final widget = - context.dependOnInheritedWidgetOfExactType(); - if (widget == null) { - throw Exception( - 'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]', - ); - } - return widget; + @override + operator ==(Object other) { + return other is TrackPresentationOptions && + other.collection == collection && + other.title == title && + other.description == description && + other.image == image && + other.routePath == routePath && + other.tracks == tracks && + other.pagination == pagination && + other.isLiked == isLiked && + other.shareUrl == shareUrl && + other.onHeart == onHeart; } + + @override + int get hashCode => + super.hashCode ^ + collection.hashCode ^ + title.hashCode ^ + description.hashCode ^ + image.hashCode ^ + routePath.hashCode ^ + tracks.hashCode ^ + pagination.hashCode ^ + isLiked.hashCode ^ + shareUrl.hashCode ^ + onHeart.hashCode; } diff --git a/lib/components/track_presentation/presentation_state.dart b/lib/components/track_presentation/presentation_state.dart new file mode 100644 index 00000000..11ca9809 --- /dev/null +++ b/lib/components/track_presentation/presentation_state.dart @@ -0,0 +1,157 @@ +import 'package:collection/collection.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/modules/library/user_local_tracks.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class PresentationState { + final List selectedTracks; + final List presentationTracks; + final SortBy sortBy; + + const PresentationState({ + required this.selectedTracks, + required this.presentationTracks, + required this.sortBy, + }); + + PresentationState copyWith({ + List? selectedTracks, + List? presentationTracks, + SortBy? sortBy, + }) { + return PresentationState( + selectedTracks: selectedTracks ?? this.selectedTracks, + presentationTracks: presentationTracks ?? this.presentationTracks, + sortBy: sortBy ?? this.sortBy, + ); + } +} + +class PresentationStateNotifier + extends AutoDisposeFamilyNotifier { + @override + PresentationState build(collection) { + final isPlaylist = arg is PlaylistSimple; + + if ((isPlaylist && (arg as PlaylistSimple).id != "user-liked-tracks") || + arg is AlbumSimple) { + ref.listen( + isPlaylist + ? playlistTracksProvider((arg as PlaylistSimple).id!) + : albumTracksProvider((arg as AlbumSimple)), + (previous, next) { + next.whenData((value) { + state = state.copyWith( + presentationTracks: ServiceUtils.sortTracks( + value.items, + state.sortBy, + ), + ); + }); + }, + ); + } + + return PresentationState( + selectedTracks: [], + presentationTracks: tracks, + sortBy: SortBy.none, + ); + } + + List get tracks { + assert( + arg is PlaylistSimple || arg is AlbumSimple, + "arg must be PlaylistSimple or AlbumSimple", + ); + + final isPlaylist = arg is PlaylistSimple; + final isSavedTrackPlaylist = + isPlaylist && (arg as PlaylistSimple).id == "user-liked-tracks"; + final tracks = switch ((isPlaylist, isSavedTrackPlaylist)) { + (true, true) => ref.read(likedTracksProvider).asData?.value, + (true, false) => ref + .read(playlistTracksProvider((arg as PlaylistSimple).id!)) + .asData + ?.value + .items, + _ => ref + .read(albumTracksProvider((arg as AlbumSimple))) + .asData + ?.value + .items, + } ?? + []; + + return tracks; + } + + void selectTrack(Track track) { + if (state.selectedTracks.any((e) => e.id == track.id)) { + return; + } + + state = state.copyWith( + selectedTracks: [...state.selectedTracks, track], + ); + } + + void selectAllTracks() { + state = state.copyWith( + selectedTracks: tracks, + ); + } + + void deselectTrack(Track track) { + state = state.copyWith( + selectedTracks: state.selectedTracks.where((e) => e != track).toList(), + ); + } + + void deselectAllTracks() { + state = state.copyWith( + selectedTracks: [], + ); + } + + void filterTracks(String query) { + if (query.isEmpty) { + return; + } + + state = state.copyWith( + presentationTracks: ServiceUtils.sortTracks( + tracks + .map((e) => (weightedRatio(e.name!, query), e)) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList(), + state.sortBy, + ), + ); + } + + void clearFilter() { + state = state.copyWith( + presentationTracks: ServiceUtils.sortTracks(tracks, state.sortBy), + ); + } + + void sortTracks(SortBy sortBy) { + state = state.copyWith( + presentationTracks: sortBy == SortBy.none + ? tracks + : ServiceUtils.sortTracks(state.presentationTracks, sortBy), + sortBy: sortBy, + ); + } +} + +final presentationStateProvider = AutoDisposeNotifierProviderFamily< + PresentationStateNotifier, PresentationState, Object>( + () => PresentationStateNotifier(), +); diff --git a/lib/components/track_presentation/presentation_top.dart b/lib/components/track_presentation/presentation_top.dart new file mode 100644 index 00000000..59854aaf --- /dev/null +++ b/lib/components/track_presentation/presentation_top.dart @@ -0,0 +1,262 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/services.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:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/heart_button/heart_button.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/use_action_callbacks.dart'; +import 'package:spotube/components/track_presentation/use_is_user_playlist.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; + +class TrackPresentationTopSection extends HookConsumerWidget { + const TrackPresentationTopSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final mediaQuery = MediaQuery.sizeOf(context); + final options = TrackPresentationOptions.of(context); + final scale = context.theme.scaling; + final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId); + + final imageDimension = mediaQuery.mdAndUp ? 200 : 120; + + final (:isLoading, :isActive, :onPlay, :onShuffle) = + useActionCallbacks(ref); + + final playbackActions = Row( + spacing: 8 * scale, + children: [ + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.shuffle_playlist), + ), + child: IconButton.secondary( + icon: isLoading + ? const Center( + child: + CircularProgressIndicator(onSurface: false, size: 20), + ) + : const Icon(SpotubeIcons.shuffle), + enabled: !isLoading && !isActive, + onPressed: onShuffle, + ), + ), + if (mediaQuery.width <= 320) + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.add_to_queue), + ), + child: IconButton.secondary( + icon: const Icon(SpotubeIcons.queueAdd), + enabled: !isLoading && !isActive, + onPressed: () {}, + ), + ) + else + Button.secondary( + leading: const Icon(SpotubeIcons.add), + enabled: !isLoading && !isActive, + child: Text(context.l10n.queue), + onPressed: () {}, + ), + Button.primary( + alignment: Alignment.center, + leading: switch ((isActive, isLoading)) { + (true, false) => const Icon(SpotubeIcons.pause), + (false, true) => const Center( + child: CircularProgressIndicator(onSurface: true, size: 18), + ), + _ => const Icon(SpotubeIcons.play), + }, + onPressed: onPlay, + enabled: !isLoading && !isActive, + child: isActive ? Text(context.l10n.pause) : Text(context.l10n.play), + ), + ], + ); + + final additionalActions = Row( + spacing: 8 * scale, + children: [ + if (isUserPlaylist) + IconButton.outline( + size: ButtonSize.small, + icon: const Icon(SpotubeIcons.edit), + onPressed: () { + showDialog( + context: context, + builder: (context) { + return PlaylistCreateDialog( + playlistId: options.collectionId, + trackIds: options.tracks.map((e) => e.id!).toList(), + ); + }, + ); + }, + ), + if (options.shareUrl != null) + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.share), + ), + child: IconButton.outline( + icon: const Icon(SpotubeIcons.share), + size: ButtonSize.small, + onPressed: () async { + await Clipboard.setData( + ClipboardData(text: options.shareUrl!), + ); + + if (!context.mounted) return; + + showToast( + context: context, + location: ToastLocation.topRight, + builder: (context, overlay) { + return SurfaceCard( + child: Text( + context.l10n + .copied_shareurl_to_clipboard(options.shareUrl!), + ).small(), + ); + }, + ); + }, + ), + ), + if (options.onHeart != null) + HeartButton( + isLiked: options.isLiked, + tooltip: options.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, + variance: ButtonVariance.outline, + size: ButtonSize.small, + onPressed: options.onHeart, + ), + ], + ); + + return SliverMainAxisGroup( + slivers: [ + if (mediaQuery.mdAndUp) SliverGap(16 * scale), + SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: (mediaQuery.mdAndUp ? 16 : 8.0) * scale, + ), + sliver: SliverList.list( + children: [ + DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: UniversalImage.imageProvider(options.image), + fit: BoxFit.cover, + ), + borderRadius: BorderRadius.circular(45), + ), + child: OutlinedContainer( + surfaceOpacity: context.theme.surfaceOpacity, + surfaceBlur: context.theme.surfaceBlur, + padding: EdgeInsets.all(24 * scale), + borderRadius: BorderRadius.circular(22 * scale), + borderWidth: 2, + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16 * scale, + children: [ + Row( + spacing: 16 * scale, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: imageDimension * scale, + width: imageDimension * scale, + decoration: BoxDecoration( + borderRadius: context.theme.borderRadiusXl, + image: DecorationImage( + image: + UniversalImage.imageProvider(options.image), + fit: BoxFit.cover, + ), + ), + ), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AutoSizeText( + options.title, + maxLines: 2, + minFontSize: 16, + style: context.theme.typography.h3, + ), + if (options.description != null) + AutoSizeText( + options.description!, + maxLines: 2, + minFontSize: 14, + maxFontSize: 18, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: context + .theme.colorScheme.mutedForeground, + fontSize: 18, + ), + ), + const Gap(16), + Flex( + crossAxisAlignment: CrossAxisAlignment.start, + direction: mediaQuery.smAndUp + ? Axis.horizontal + : Axis.vertical, + spacing: 8 * scale, + children: [ + if (options.owner != null) + OutlineBadge( + leading: options.ownerImage != null + ? Avatar( + initials: + options.owner?[0] ?? "U", + provider: UniversalImage + .imageProvider( + options.ownerImage!, + ), + ) + : null, + child: Text( + options.owner!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ).small(), + ), + additionalActions, + ], + ), + if (mediaQuery.mdAndUp) ...[ + const Gap(16), + playbackActions + ], + ], + ), + ), + ], + ), + if (mediaQuery.smAndDown) playbackActions, + ], + ), + ), + ), + ], + ), + ) + ], + ); + } +} diff --git a/lib/components/track_presentation/sort_tracks_dropdown.dart b/lib/components/track_presentation/sort_tracks_dropdown.dart new file mode 100644 index 00000000..543bacb3 --- /dev/null +++ b/lib/components/track_presentation/sort_tracks_dropdown.dart @@ -0,0 +1,70 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/modules/library/user_local_tracks.dart'; +import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/extensions/context.dart'; + +class SortTracksDropdown extends StatelessWidget { + final SortBy? value; + final void Function(SortBy)? onChanged; + const SortTracksDropdown({ + this.onChanged, + this.value, + super.key, + }); + + @override + Widget build(BuildContext context) { + return AdaptivePopSheetList( + variance: ButtonVariance.outline, + headings: [ + Text(context.l10n.sort_tracks), + ], + onSelected: onChanged, + tooltip: context.l10n.sort_tracks, + icon: const Icon(SpotubeIcons.sort), + children: [ + AdaptiveMenuButton( + value: SortBy.none, + enabled: value != SortBy.none, + child: Text(context.l10n.none), + ), + AdaptiveMenuButton( + value: SortBy.ascending, + enabled: value != SortBy.ascending, + child: Text(context.l10n.sort_a_z), + ), + AdaptiveMenuButton( + value: SortBy.descending, + enabled: value != SortBy.descending, + child: Text(context.l10n.sort_z_a), + ), + AdaptiveMenuButton( + value: SortBy.newest, + enabled: value != SortBy.newest, + child: Text(context.l10n.sort_newest), + ), + AdaptiveMenuButton( + value: SortBy.oldest, + enabled: value != SortBy.oldest, + child: Text(context.l10n.sort_oldest), + ), + AdaptiveMenuButton( + value: SortBy.duration, + enabled: value != SortBy.duration, + child: Text(context.l10n.sort_duration), + ), + AdaptiveMenuButton( + value: SortBy.artist, + enabled: value != SortBy.artist, + child: Text(context.l10n.sort_artist), + ), + AdaptiveMenuButton( + value: SortBy.album, + enabled: value != SortBy.album, + child: Text(context.l10n.sort_album), + ), + ], + ); + } +} diff --git a/lib/components/track_presentation/track_presentation.dart b/lib/components/track_presentation/track_presentation.dart new file mode 100644 index 00000000..8bc1c6df --- /dev/null +++ b/lib/components/track_presentation/track_presentation.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart' show ListTile; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/components/track_presentation/presentation_list.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/presentation_top.dart'; +import 'package:spotube/components/track_presentation/presentation_modifiers.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; + +class TrackPresentation extends HookConsumerWidget { + final TrackPresentationOptions options; + const TrackPresentation({ + super.key, + required this.options, + }); + + @override + Widget build(BuildContext context, ref) { + final headerTextStyle = context.theme.typography.small.copyWith( + color: context.theme.colorScheme.mutedForeground, + ); + + return Data.inherit( + data: options, + child: SafeArea( + child: Scaffold( + headers: const [TitleBar()], + child: CustomScrollView( + slivers: [ + const TrackPresentationTopSection(), + const SliverGap(16), + SliverLayoutBuilder( + builder: (context, constrains) { + return SliverList.list( + children: [ + const TrackPresentationModifiersSection(), + ListTile( + titleTextStyle: headerTextStyle, + subtitleTextStyle: headerTextStyle, + leadingAndTrailingTextStyle: headerTextStyle, + leading: constrains.mdAndUp ? const Text(" #") : null, + title: Row( + children: [ + Expanded( + flex: constrains.lgAndUp ? 5 : 6, + child: Text(context.l10n.title), + ), + if (constrains.mdAndUp) + Expanded( + flex: 3, + child: Text(context.l10n.album), + ), + Text(context.l10n.duration), + ], + ), + ), + ], + ); + }, + ), + const PresentationListSection(), + const SliverGap(200), + ], + ), + ), + ), + ); + } +} diff --git a/lib/components/track_presentation/use_action_callbacks.dart b/lib/components/track_presentation/use_action_callbacks.dart new file mode 100644 index 00000000..e9b9c98e --- /dev/null +++ b/lib/components/track_presentation/use_action_callbacks.dart @@ -0,0 +1,135 @@ +import 'dart:math'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; + +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +typedef UseActionCallbacks = ({ + bool isActive, + bool isLoading, + Future Function() onShuffle, + Future Function() onPlay, +}); + +UseActionCallbacks useActionCallbacks(WidgetRef ref) { + final isLoading = useState(false); + final context = useContext(); + final options = TrackPresentationOptions.of(context); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); + + final isActive = useMemoized( + () => playlist.collections.contains(options.collectionId), + [playlist.collections, options.collectionId], + ); + + final onShuffle = useCallback(() async { + try { + isLoading.value = true; + + final initialTracks = options.tracks; + if (!context.mounted) return; + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final allTracks = await options.pagination.onFetchAll(); + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + options.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: options.collection as AlbumSimple, + initialIndex: Random().nextInt(allTracks.length)) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: options.collection as PlaylistSimple, + initialIndex: Random().nextInt(allTracks.length), + ), + ); + await remotePlayback.setShuffle(true); + } else { + await playlistNotifier.load( + initialTracks, + autoPlay: true, + initialIndex: Random().nextInt(initialTracks.length), + ); + await audioPlayer.setShuffle(true); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([options.collection as PlaylistSimple]); + } + + final allTracks = await options.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); + } + } finally { + isLoading.value = false; + } + }, [options, playlistNotifier, historyNotifier]); + + final onPlay = useCallback(() async { + try { + isLoading.value = true; + + final initialTracks = options.tracks; + + if (!context.mounted) return; + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final allTracks = await options.pagination.onFetchAll(); + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + options.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: options.collection as AlbumSimple, + ) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: options.collection as PlaylistSimple, + ), + ); + } else { + await playlistNotifier.load(initialTracks, autoPlay: true); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([options.collection as PlaylistSimple]); + } + + final allTracks = await options.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); + } + } finally { + if (context.mounted) { + isLoading.value = false; + } + } + }, [options, playlistNotifier, historyNotifier]); + + return ( + isActive: isActive, + isLoading: isLoading.value, + onShuffle: onShuffle, + onPlay: onPlay, + ); +} diff --git a/lib/components/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/track_presentation/use_is_user_playlist.dart similarity index 100% rename from lib/components/tracks_view/sections/body/use_is_user_playlist.dart rename to lib/components/track_presentation/use_is_user_playlist.dart diff --git a/lib/components/track_presentation/use_track_tile_play_callback.dart b/lib/components/track_presentation/use_track_tile_play_callback.dart new file mode 100644 index 00000000..261d01d8 --- /dev/null +++ b/lib/components/track_presentation/use_track_tile_play_callback.dart @@ -0,0 +1,84 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/presentation_state.dart'; +import 'package:spotube/extensions/list.dart'; + +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; + +Future Function(Track track, int index) useTrackTilePlayCallback( + WidgetRef ref, +) { + final context = useContext(); + final options = TrackPresentationOptions.of(context); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); + + final isActive = useMemoized( + () => playlist.collections.contains(options.collectionId), + [playlist.collections, options.collectionId], + ); + + final onTapTrackTile = useCallback((Track track, int index) async { + final state = ref.read(presentationStateProvider(options.collection)); + final notifier = + ref.read(presentationStateProvider(options.collection).notifier); + + if (state.selectedTracks.isNotEmpty) { + notifier.selectTrack(track); + return; + } + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remoteQueue = ref.read(queueProvider); + if (remoteQueue.collections.contains(options.collectionId) || + remoteQueue.tracks.any((s) => s.id == track.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await options.pagination.onFetchAll(); + await remotePlayback.load( + options.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: tracks, + collection: options.collection as AlbumSimple, + initialIndex: index, + ) + : WebSocketLoadEventData.playlist( + tracks: tracks, + collection: options.collection as PlaylistSimple, + initialIndex: index, + ), + ); + } + } else { + if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await options.pagination.onFetchAll(); + await playlistNotifier.load( + tracks, + initialIndex: index, + autoPlay: true, + ); + playlistNotifier.addCollection(options.collectionId); + if (options.collection is AlbumSimple) { + historyNotifier.addAlbums([options.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([options.collection as PlaylistSimple]); + } + } + } + }, [isActive, playlist, options, playlistNotifier, historyNotifier]); + + return onTapTrackTile; +} diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index 8ab889f8..560d2255 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -1,10 +1,11 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show ListTile, Material, MaterialType; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -88,78 +89,98 @@ class TrackTile extends HookConsumerWidget { }, child: HoverBuilder( permanentState: isSelected || constrains.smAndDown ? true : null, - builder: (context, isHovering) => ListTile( - selected: isSelected, - onTap: () async { - try { - isLoading.value = true; - await onTap?.call(); - } finally { - if (context.mounted) { - isLoading.value = false; + builder: (context, isHovering) => Material( + type: MaterialType.transparency, + child: ListTile( + selectedColor: theme.colorScheme.primary, + selectedTileColor: theme.colorScheme.primary.withOpacity(0.1), + selected: isSelected, + onTap: () async { + try { + isLoading.value = true; + await onTap?.call(); + } finally { + if (context.mounted) { + isLoading.value = false; + } } - } - }, - onLongPress: onLongPress, - enabled: !isBlackListed, - contentPadding: EdgeInsets.zero, - tileColor: isBlackListed ? theme.colorScheme.errorContainer : null, - horizontalTitleGap: 12, - leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...?leadingActions, - if (index != null && onChanged == null && constrains.mdAndUp) - SizedBox( - width: 50, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '${(index ?? 0) + 1}', - maxLines: 1, - style: theme.textTheme.bodySmall, - textAlign: TextAlign.center, - ), + }, + onLongPress: onLongPress, + enabled: !isBlackListed, + contentPadding: EdgeInsets.zero, + tileColor: isBlackListed ? theme.colorScheme.destructive : null, + horizontalTitleGap: 12, + leadingAndTrailingTextStyle: theme.typography.normal.copyWith( + color: theme.colorScheme.foreground, + ), + titleTextStyle: theme.typography.normal.copyWith( + color: theme.colorScheme.foreground, + ), + subtitleTextStyle: theme.typography.xSmall.copyWith( + color: theme.colorScheme.mutedForeground, + ), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...?leadingActions, + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: index != null && onChanged == null + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: Checkbox( + state: selected + ? CheckboxState.checked + : CheckboxState.unchecked, + onChanged: (state) => + onChanged?.call(state == CheckboxState.checked), ), - ) - else if (constrains.smAndDown) - const SizedBox(width: 16), - if (onChanged != null) - Checkbox( - value: selected, - onChanged: onChanged, - ), - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: AspectRatio( - aspectRatio: 1, - child: UniversalImage( - path: (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, + secondChild: constrains.smAndDown + ? const SizedBox(width: 16) + : SizedBox( + width: 50, + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '${(index ?? 0) + 1}', + maxLines: 1, + style: theme.typography.small, + textAlign: TextAlign.center, + ), + ), ), - fit: BoxFit.cover, - ), - ), - ), - Positioned.fill( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), + ), + Stack( + children: [ + Container( + height: 40, + width: 40, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: isHovering - ? Colors.black.withOpacity(0.4) - : Colors.transparent, + borderRadius: theme.borderRadiusMd, + image: DecorationImage( + fit: BoxFit.cover, + image: UniversalImage.imageProvider( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + ), + ), ), ), - ), - Positioned.fill( - child: Center( - child: IconTheme( - data: theme.iconTheme - .copyWith(size: 26, color: Colors.white), + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: BoxDecoration( + borderRadius: theme.borderRadiusMd, + color: isHovering + ? Colors.black.withOpacity(0.4) + : Colors.transparent, + ), + ), + ), + Positioned.fill( + child: Center( child: Skeleton.ignore( child: Consumer( builder: (context, ref, _) { @@ -167,119 +188,126 @@ class TrackTile extends HookConsumerWidget { ref.watch(queryingTrackInfoProvider); return AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: (isPlaying && isFetchingActiveTrack) || - isLoading.value - ? const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: Colors.white, - ), - ) - : isPlaying - ? Icon( - SpotubeIcons.pause, - color: theme.colorScheme.primary, - ) - : !isHovering - ? const SizedBox.shrink() - : const Icon(SpotubeIcons.play), + child: switch (( + isPlaying, + isFetchingActiveTrack, + isPlaying, + isHovering, + isLoading.value + )) { + (true, true, _, _, _) || + (_, _, _, _, true) => + const SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator( + size: 1.5), + ), + (_, _, true, _, _) => Icon( + SpotubeIcons.pause, + color: theme.colorScheme.primary, + ), + (_, _, _, true, _) => const Icon( + SpotubeIcons.play, + color: Colors.white, + ), + _ => const SizedBox.shrink(), + }, ); }, ), ), ), ), - ), - ], - ), - ], - ), - title: Row( - children: [ - Expanded( - flex: 6, - child: switch (track) { - LocalTrack() => Text( - track.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - _ => LinkText( - track.name!, - "/track/${track.id}", - push: true, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - }, - ), - if (constrains.mdAndUp) ...[ - const SizedBox(width: 8), + ], + ), + ], + ), + title: Row( + children: [ Expanded( - flex: 4, + flex: 6, child: switch (track) { LocalTrack() => Text( - track.album!.name!, + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _ => LinkText( + track.name!, + "/track/${track.id}", + push: true, maxLines: 1, overflow: TextOverflow.ellipsis, ), - _ => Align( - alignment: Alignment.centerLeft, - child: LinkText( - track.album!.name!, - "/album/${track.album?.id}", - extra: track.album, - push: true, - overflow: TextOverflow.ellipsis, - ), - ) }, ), + if (constrains.mdAndUp) ...[ + const SizedBox(width: 8), + Expanded( + flex: 4, + child: switch (track) { + LocalTrack() => Text( + track.album!.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _ => Align( + alignment: Alignment.centerLeft, + child: LinkText( + track.album!.name!, + "/album/${track.album?.id}", + extra: track.album, + push: true, + overflow: TextOverflow.ellipsis, + ), + ) + }, + ), + ], ], - ], - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: track is LocalTrack - ? Text( - track.artists?.asString() ?? '', - ) - : ClipRect( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 40), - child: ArtistLink( - artists: track.artists ?? [], - onOverflowArtistClick: () => ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track.id!, - }, + ), + subtitle: Align( + alignment: Alignment.centerLeft, + child: track is LocalTrack + ? Text( + track.artists?.asString() ?? '', + ) + : ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 40), + child: ArtistLink( + artists: track.artists ?? [], + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ), ), ), ), - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 8), - Text( - Duration(milliseconds: track.durationMs ?? 0) - .toHumanReadableString(padZero: false), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - TrackOptions( - track: track, - playlistId: playlistId, - userPlaylist: userPlaylist, - showMenuCbRef: showOptionCbRef, - ), - if (kIsDesktop) const Gap(10), - ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + Duration(milliseconds: track.durationMs ?? 0) + .toHumanReadableString(padZero: false), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + TrackOptions( + track: track, + playlistId: playlistId, + userPlaylist: userPlaylist, + showMenuCbRef: showOptionCbRef, + ), + if (kIsDesktop) const Gap(10), + ], + ), ), ), ), diff --git a/lib/components/tracks_view/sections/body/track_view_body.dart b/lib/components/tracks_view/sections/body/track_view_body.dart deleted file mode 100644 index 0f161b0c..00000000 --- a/lib/components/tracks_view/sections/body/track_view_body.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/expandable_search/expandable_search.dart'; -import 'package:spotube/components/track_tile/track_tile.dart'; -import 'package:spotube/components/tracks_view/sections/body/track_view_body_headers.dart'; -import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/components/tracks_view/track_view_provider.dart'; -import 'package:spotube/extensions/list.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:very_good_infinite_list/very_good_infinite_list.dart'; - -class TrackViewBodySection extends HookConsumerWidget { - const TrackViewBodySection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - final props = InheritedTrackView.of(context); - final trackViewState = ref.watch(trackViewProvider(props.tracks)); - - final searchController = useTextEditingController(); - final searchFocus = useFocusNode(); - - useValueListenable(searchController); - final searchQuery = searchController.text; - - final isFiltering = useState(false); - - final uniqTracks = useMemoized(() { - final trackIds = props.tracks.map((e) => e.id).toSet(); - return props.tracks.where((e) => trackIds.remove(e.id)).toList(); - }, [props.tracks]); - - final tracks = useMemoized(() { - List filteredTracks; - if (searchQuery.isEmpty) { - filteredTracks = uniqTracks; - } else { - filteredTracks = uniqTracks - .map((e) => (weightedRatio(e.name!, searchQuery), e)) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - } - return ServiceUtils.sortTracks(filteredTracks, trackViewState.sortBy); - }, [trackViewState.sortBy, searchQuery, uniqTracks]); - - final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId); - - final isActive = playlist.collections.contains(props.collectionId); - - final onTapTrackTile = useCallback((Track track, int index) async { - if (trackViewState.isSelecting) { - trackViewState.toggleTrackSelection(track.id!); - return; - } - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - final remoteQueue = ref.read(queueProvider); - if (remoteQueue.collections.contains(props.collectionId) || - remoteQueue.tracks.any((s) => s.id == track.id)) { - await playlistNotifier.jumpToTrack(track); - } else { - final tracks = await props.pagination.onFetchAll(); - await remotePlayback.load( - props.collection is AlbumSimple - ? WebSocketLoadEventData.album( - tracks: tracks, - collection: props.collection as AlbumSimple, - initialIndex: index, - ) - : WebSocketLoadEventData.playlist( - tracks: tracks, - collection: props.collection as PlaylistSimple, - initialIndex: index, - ), - ); - } - } else { - if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) { - await playlistNotifier.jumpToTrack(track); - } else { - final tracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - tracks, - initialIndex: index, - autoPlay: true, - ); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier.addPlaylists([props.collection as PlaylistSimple]); - } - } - } - }, [isActive, playlist, props, playlistNotifier, historyNotifier]); - - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: TrackViewBodyHeaders( - isFiltering: isFiltering, - searchFocus: searchFocus, - ), - ), - const SliverGap(8), - SliverToBoxAdapter( - child: ExpandableSearchField( - isFiltering: isFiltering.value, - onChangeFiltering: (value) { - isFiltering.value = value; - }, - searchController: searchController, - searchFocus: searchFocus, - ), - ), - SliverSafeArea( - top: false, - sliver: SliverInfiniteList( - itemCount: tracks.length, - onFetchData: props.pagination.onFetchMore, - isLoading: props.pagination.isLoading, - hasReachedMax: !props.pagination.hasNextPage, - loadingBuilder: (context) => Skeletonizer( - enabled: true, - child: TrackTile( - playlist: playlist, - track: FakeData.track, - index: 0, - ), - ), - emptyBuilder: (context) => Skeletonizer( - enabled: true, - child: Column( - children: List.generate( - 10, - (index) => TrackTile( - track: FakeData.track, - index: index, - playlist: playlist, - ), - ), - ), - ), - itemBuilder: (context, index) { - final track = tracks[index]; - return TrackTile( - playlist: playlist, - track: track, - index: index, - selected: trackViewState.selectedTrackIds.contains(track.id!), - playlistId: props.collectionId, - userPlaylist: isUserPlaylist, - onChanged: !trackViewState.isSelecting - ? null - : (value) { - trackViewState.toggleTrackSelection(track.id!); - }, - onLongPress: () { - trackViewState.selectTrack(track.id!); - HapticFeedback.selectionClick(); - }, - onTap: () => onTapTrackTile(track, index), - ); - }, - ), - ), - ], - ); - } -} diff --git a/lib/components/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/tracks_view/sections/body/track_view_body_headers.dart deleted file mode 100644 index 82cc7706..00000000 --- a/lib/components/tracks_view/sections/body/track_view_body_headers.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/expandable_search/expandable_search.dart'; -import 'package:spotube/components/sort_tracks_dropdown.dart'; -import 'package:spotube/components/tracks_view/sections/body/track_view_options.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/components/tracks_view/track_view_provider.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/utils/platform.dart'; - -class TrackViewBodyHeaders extends HookConsumerWidget { - final ValueNotifier isFiltering; - final FocusNode searchFocus; - - const TrackViewBodyHeaders({ - super.key, - required this.isFiltering, - required this.searchFocus, - }); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:textTheme) = Theme.of(context); - final props = InheritedTrackView.of(context); - final trackViewState = ref.watch(trackViewProvider(props.tracks)); - return LayoutBuilder( - builder: (context, constrains) { - return Row( - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: animation, - child: child, - ), - ); - }, - child: Checkbox( - value: trackViewState.hasSelectedAll, - onChanged: (checked) { - if (checked == true) { - trackViewState.selectAll(); - } else { - trackViewState.deselectAll(); - } - }, - ), - ), - Expanded( - flex: 7, - child: Row( - children: [ - Text( - context.l10n.title, - style: textTheme.bodyLarge, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - // used alignment of this table-head - if (constrains.mdAndUp) - Expanded( - flex: 3, - child: Row( - children: [ - Text( - context.l10n.album, - overflow: TextOverflow.ellipsis, - style: textTheme.bodyLarge, - ), - ], - ), - ), - SortTracksDropdown( - value: trackViewState.sortBy, - onChanged: (value) { - trackViewState.sort(value); - }, - ), - ExpandableSearchButton( - isFiltering: isFiltering.value, - searchFocus: searchFocus, - onPressed: (value) { - isFiltering.value = value; - if (value) { - searchFocus.requestFocus(); - } else { - searchFocus.unfocus(); - } - }, - ), - const TrackViewBodyOptions(), - if (kIsDesktop) const Gap(10), - ], - ); - }, - ); - } -} diff --git a/lib/components/tracks_view/sections/body/track_view_options.dart b/lib/components/tracks_view/sections/body/track_view_options.dart deleted file mode 100644 index 7114d713..00000000 --- a/lib/components/tracks_view/sections/body/track_view_options.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/dialogs/confirm_download_dialog.dart'; -import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/components/tracks_view/track_view_provider.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class TrackViewBodyOptions extends HookConsumerWidget { - const TrackViewBodyOptions({super.key}); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - final ThemeData(:textTheme) = Theme.of(context); - - ref.watch(downloadManagerProvider); - final downloader = ref.watch(downloadManagerProvider.notifier); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - final audioSource = - ref.watch(userPreferencesProvider.select((s) => s.audioSource)); - - final trackViewState = ref.watch(trackViewProvider(props.tracks)); - final selectedTracks = trackViewState.selectedTracks; - - return AdaptivePopSheetList( - tooltip: context.l10n.more_actions, - headings: [ - Text( - context.l10n.more_actions, - style: textTheme.bodyLarge, - ), - ], - onSelected: (action) async { - switch (action) { - case "download": - { - final confirmed = audioSource == AudioSource.piped || - await showDialog( - context: context, - builder: (context) { - return const ConfirmDownloadDialog(); - }, - ); - if (confirmed != true) return; - await downloader.batchAddToQueue(selectedTracks); - trackViewState.deselectAll(); - break; - } - case "add-to-playlist": - { - if (context.mounted) { - await showDialog( - context: context, - builder: (context) { - return PlaylistAddTrackDialog( - openFromPlaylist: props.collectionId, - tracks: selectedTracks.toList(), - ); - }, - ); - } - break; - } - case "play-next": - { - playlistNotifier.addTracksAtFirst(selectedTracks); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } - trackViewState.deselectAll(); - break; - } - case "add-to-queue": - { - playlistNotifier.addTracks(selectedTracks); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } - trackViewState.deselectAll(); - break; - } - default: - } - }, - icon: const Icon(SpotubeIcons.moreVertical), - children: [ - AdaptiveMenuButton( - value: "download", - leading: const Icon(SpotubeIcons.download), - enabled: selectedTracks.isNotEmpty, - child: Text( - context.l10n.download_count(selectedTracks.length), - ), - ), - AdaptiveMenuButton( - value: "add-to-playlist", - leading: const Icon(SpotubeIcons.playlistAdd), - enabled: selectedTracks.isNotEmpty, - child: Text( - context.l10n.add_count_to_playlist(selectedTracks.length), - ), - ), - AdaptiveMenuButton( - enabled: selectedTracks.isNotEmpty, - value: "add-to-queue", - leading: const Icon(SpotubeIcons.queueAdd), - child: Text( - context.l10n.add_count_to_queue(selectedTracks.length), - ), - ), - AdaptiveMenuButton( - enabled: selectedTracks.isNotEmpty, - value: "play-next", - leading: const Icon(SpotubeIcons.lightning), - child: Text( - context.l10n.play_count_next(selectedTracks.length), - ), - ), - ], - ); - } -} diff --git a/lib/components/tracks_view/sections/header/flexible_header.dart b/lib/components/tracks_view/sections/header/flexible_header.dart deleted file mode 100644 index 508d289c..00000000 --- a/lib/components/tracks_view/sections/header/flexible_header.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/tracks_view/sections/header/header_actions.dart'; -import 'package:spotube/components/tracks_view/sections/header/header_buttons.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:gap/gap.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/string.dart'; -import 'package:spotube/hooks/utils/use_palette_color.dart'; -import 'package:spotube/utils/platform.dart'; - -class TrackViewFlexHeader extends HookConsumerWidget { - const TrackViewFlexHeader({super.key}); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - final ThemeData(:colorScheme, :textTheme, :iconTheme) = Theme.of(context); - final defaultTextStyle = DefaultTextStyle.of(context); - final mediaQuery = MediaQuery.of(context); - - final palette = usePaletteColor(props.image, ref); - - return IconTheme( - data: iconTheme.copyWith(color: palette.bodyTextColor), - child: SliverLayoutBuilder( - builder: (context, constrains) { - final isExpanded = constrains.scrollOffset < 350; - - final headingStyle = (mediaQuery.mdAndDown - ? textTheme.headlineSmall - : textTheme.headlineMedium) - ?.copyWith( - color: palette.bodyTextColor, - ); - return SliverAppBar( - iconTheme: iconTheme.copyWith( - color: palette.bodyTextColor, - size: 16, - ), - actions: isExpanded - ? [] - : [ - const TrackViewHeaderActions(), - TrackViewHeaderButtons(compact: true, color: palette), - ], - floating: false, - pinned: true, - expandedHeight: 450, - automaticallyImplyLeading: kIsMobile, - backgroundColor: palette.color, - title: isExpanded ? null : Text(props.title, style: headingStyle), - flexibleSpace: FlexibleSpaceBar( - background: Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - image: DecorationImage( - image: UniversalImage.imageProvider(props.image), - fit: BoxFit.cover, - ), - ), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.black45, - colorScheme.surface, - ], - begin: const FractionalOffset(0, 0), - end: const FractionalOffset(0, 1), - tileMode: TileMode.clamp, - ), - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: mediaQuery.mdAndDown - ? mediaQuery.size.width - : 800, - ), - child: Flex( - direction: mediaQuery.mdAndDown - ? Axis.vertical - : Axis.horizontal, - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - path: props.image, - width: 200, - height: 200, - placeholder: Assets.albumPlaceholder.path, - ), - ), - const Gap(20), - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: mediaQuery.mdAndDown - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - Text( - props.title, - style: headingStyle, - textAlign: mediaQuery.mdAndDown - ? TextAlign.center - : TextAlign.start, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 10), - if (props.description != null && - props.description!.isNotEmpty) - Text( - props.description! - .unescapeHtml() - .cleanHtml(), - style: - defaultTextStyle.style.copyWith( - color: palette.bodyTextColor, - ), - textAlign: mediaQuery.mdAndDown - ? TextAlign.center - : TextAlign.start, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const Gap(10), - const TrackViewHeaderActions(), - const Gap(10), - TrackViewHeaderButtons(color: palette), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - }, - ), - ); - } -} diff --git a/lib/components/tracks_view/sections/header/header_actions.dart b/lib/components/tracks_view/sections/header/header_actions.dart deleted file mode 100644 index 8e378f97..00000000 --- a/lib/components/tracks_view/sections/header/header_actions.dart +++ /dev/null @@ -1,111 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/heart_button/heart_button.dart'; -import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; - -class TrackViewHeaderActions extends HookConsumerWidget { - const TrackViewHeaderActions({super.key}); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - - final isActive = playlist.collections.contains(props.collectionId); - - final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId); - - final scaffoldMessenger = ScaffoldMessenger.of(context); - - final auth = ref.watch(authenticationProvider); - - final copiedText = - context.l10n.copied_shareurl_to_clipboard(props.shareUrl); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: context.l10n.share, - icon: const Icon(SpotubeIcons.share), - onPressed: () async { - await Clipboard.setData( - ClipboardData(text: props.shareUrl), - ); - - scaffoldMessenger.showSnackBar( - SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - copiedText, - textAlign: TextAlign.center, - ), - ), - ); - }, - ), - IconButton( - icon: const Icon(SpotubeIcons.queueAdd), - tooltip: context.l10n.add_to_queue, - onPressed: isActive || props.tracks.isEmpty - ? null - : () async { - final tracks = await props.pagination.onFetchAll(); - await playlistNotifier.addTracks(tracks); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier - .addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } - }, - ), - if (props.onHeart != null && auth.asData?.value != null) - HeartButton( - isLiked: props.isLiked, - icon: isUserPlaylist ? SpotubeIcons.trash : null, - tooltip: props.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - onPressed: () async { - final shouldPop = await props.onHeart?.call(); - if (isUserPlaylist && shouldPop == true && context.mounted) { - context.pop(); - } - }, - ), - if (isUserPlaylist) - IconButton( - icon: const Icon(SpotubeIcons.edit), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return PlaylistCreateDialog( - playlistId: props.collectionId, - trackIds: props.tracks.map((e) => e.id!).toList(), - ); - }, - ); - }, - ), - ], - ); - } -} diff --git a/lib/components/tracks_view/sections/header/header_buttons.dart b/lib/components/tracks_view/sections/header/header_buttons.dart deleted file mode 100644 index 54e0f0cf..00000000 --- a/lib/components/tracks_view/sections/header/header_buttons.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; - -class TrackViewHeaderButtons extends HookConsumerWidget { - final PaletteColor color; - final bool compact; - const TrackViewHeaderButtons({ - super.key, - required this.color, - this.compact = false, - }); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryActionsProvider); - - final isActive = playlist.collections.contains(props.collectionId); - - final isLoading = useState(false); - - const progressIndicator = Center( - child: SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: .8), - ), - ); - - void onShuffle() async { - try { - isLoading.value = true; - - final initialTracks = props.tracks; - if (!context.mounted) return; - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice) { - final allTracks = await props.pagination.onFetchAll(); - final remotePlayback = ref.read(connectProvider.notifier); - await remotePlayback.load( - props.collection is AlbumSimple - ? WebSocketLoadEventData.album( - tracks: allTracks, - collection: props.collection as AlbumSimple, - initialIndex: Random().nextInt(allTracks.length)) - : WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: props.collection as PlaylistSimple, - initialIndex: Random().nextInt(allTracks.length), - ), - ); - await remotePlayback.setShuffle(true); - } else { - await playlistNotifier.load( - initialTracks, - autoPlay: true, - initialIndex: Random().nextInt(initialTracks.length), - ); - await audioPlayer.setShuffle(true); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier.addPlaylists([props.collection as PlaylistSimple]); - } - - final allTracks = await props.pagination.onFetchAll(); - - await playlistNotifier.addTracks( - allTracks.sublist(initialTracks.length), - ); - } - } finally { - isLoading.value = false; - } - } - - void onPlay() async { - try { - isLoading.value = true; - - final initialTracks = props.tracks; - - if (!context.mounted) return; - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice) { - final allTracks = await props.pagination.onFetchAll(); - final remotePlayback = ref.read(connectProvider.notifier); - await remotePlayback.load( - props.collection is AlbumSimple - ? WebSocketLoadEventData.album( - tracks: allTracks, - collection: props.collection as AlbumSimple, - ) - : WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: props.collection as PlaylistSimple, - ), - ); - } else { - await playlistNotifier.load(initialTracks, autoPlay: true); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier.addPlaylists([props.collection as PlaylistSimple]); - } - - final allTracks = await props.pagination.onFetchAll(); - - await playlistNotifier.addTracks( - allTracks.sublist(initialTracks.length), - ); - } - } finally { - if (context.mounted) { - isLoading.value = false; - } - } - } - - if (compact) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isActive && !isLoading.value) - IconButton( - icon: const Icon(SpotubeIcons.shuffle), - onPressed: props.tracks.isEmpty ? null : onShuffle, - ), - const Gap(10), - IconButton.filledTonal( - icon: isActive - ? const Icon(SpotubeIcons.pause) - : isLoading.value - ? progressIndicator - : const Icon(SpotubeIcons.play), - onPressed: isActive || props.tracks.isEmpty || isLoading.value - ? null - : onPlay, - ), - const Gap(10), - ], - ); - } - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: isActive || isLoading.value ? 0 : 1, - child: AnimatedSize( - duration: const Duration(milliseconds: 300), - child: SizedBox.square( - dimension: isActive || isLoading.value ? 0 : null, - child: FilledButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - minimumSize: const Size(150, 40)), - label: Text(context.l10n.shuffle), - icon: const Icon(SpotubeIcons.shuffle), - onPressed: props.tracks.isEmpty ? null : onShuffle, - ), - ), - ), - ), - const Gap(10), - FilledButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: color.color, - foregroundColor: color.bodyTextColor, - minimumSize: const Size(150, 40)), - onPressed: isActive || props.tracks.isEmpty || isLoading.value - ? null - : onPlay, - icon: isActive - ? const Icon(SpotubeIcons.pause) - : isLoading.value - ? progressIndicator - : const Icon(SpotubeIcons.play), - label: Text(context.l10n.play), - ), - ], - ); - } -} diff --git a/lib/components/tracks_view/track_view.dart b/lib/components/tracks_view/track_view.dart deleted file mode 100644 index fa6011e0..00000000 --- a/lib/components/tracks_view/track_view.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/tracks_view/sections/header/flexible_header.dart'; -import 'package:spotube/components/tracks_view/sections/body/track_view_body.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; -import 'package:spotube/utils/platform.dart'; - -class TrackView extends HookConsumerWidget { - const TrackView({super.key}); - - @override - Widget build(BuildContext context, ref) { - final props = InheritedTrackView.of(context); - final controller = useScrollController(); - - return Scaffold( - appBar: kIsDesktop - ? const TitleBar( - backgroundColor: Colors.transparent, - leading: [ - Align( - alignment: Alignment.centerLeft, - child: BackButton(color: Colors.white), - ) - ], - ) - : null, - extendBodyBehindAppBar: true, - body: RefreshIndicator( - onRefresh: props.pagination.onRefresh, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: const [ - TrackViewFlexHeader(), - SliverAnimatedSwitcher( - duration: Duration(milliseconds: 500), - child: TrackViewBodySection(), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/components/tracks_view/track_view_provider.dart b/lib/components/tracks_view/track_view_provider.dart deleted file mode 100644 index 16aa6d9c..00000000 --- a/lib/components/tracks_view/track_view_provider.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/modules/library/user_local_tracks.dart'; - -class TrackViewNotifier extends ChangeNotifier { - List tracks; - List selectedTrackIds; - SortBy sortBy; - String? searchQuery; - - TrackViewNotifier( - this.tracks, { - this.selectedTrackIds = const [], - this.sortBy = SortBy.none, - this.searchQuery, - }); - - bool get isSelecting => selectedTrackIds.isNotEmpty; - - bool get hasSelectedAll => - selectedTrackIds.length == tracks.length && tracks.isNotEmpty; - - List get selectedTracks => - tracks.where((e) => selectedTrackIds.contains(e.id)).toList(); - - void selectTrack(String trackId) { - selectedTrackIds = [...selectedTrackIds, trackId]; - notifyListeners(); - } - - void unselectTrack(String trackId) { - selectedTrackIds = selectedTrackIds.where((e) => e != trackId).toList(); - notifyListeners(); - } - - void toggleTrackSelection(String trackId) { - if (selectedTrackIds.contains(trackId)) { - unselectTrack(trackId); - } else { - selectTrack(trackId); - } - } - - void selectAll() { - selectedTrackIds = tracks.map((e) => e.id!).toList(); - notifyListeners(); - } - - void deselectAll() { - selectedTrackIds = []; - notifyListeners(); - } - - void sort(SortBy sortBy) { - this.sortBy = sortBy; - notifyListeners(); - } -} - -final trackViewProvider = ChangeNotifierProvider.autoDispose - .family>((ref, tracks) { - return TrackViewNotifier(tracks); -}); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5b9e5183..4109edb7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -402,5 +402,10 @@ "found_n_files": "Found {count} files", "export_cache_confirmation": "Do you want to export these files to", "exported_n_out_of_m_files": "Exported {filesExported} out of {files} files", - "undo": "Undo" + "undo": "Undo", + "download_all": "Download all", + "add_all_to_playlist": "Add all to playlist", + "add_all_to_queue": "Add all to queue", + "play_all_next": "Play all next", + "pause": "Pause" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 0b84d38d..ecf2cc37 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -225,6 +226,9 @@ class Spotube extends HookConsumerWidget { surfaceOpacity: .8, surfaceBlur: 10, ), + materialTheme: material.ThemeData( + splashFactory: material.NoSplash.splashFactory, + ), themeMode: themeMode, shortcuts: { ...WidgetsApp.defaultShortcuts.map((key, value) { diff --git a/lib/modules/root/sidebar.dart b/lib/modules/root/sidebar.dart index 1afa85c5..9a92a1cb 100644 --- a/lib/modules/root/sidebar.dart +++ b/lib/modules/root/sidebar.dart @@ -78,8 +78,8 @@ class Sidebar extends HookConsumerWidget { isLabelVisible: tile.title == "Library" && downloadCount > 0, label: Text( downloadCount.toString(), - style: const TextStyle( - color: Colors.white, + style: TextStyle( + color: context.theme.colorScheme.primaryForeground, fontSize: 10, ), ), diff --git a/lib/modules/settings/section_card_with_heading.dart b/lib/modules/settings/section_card_with_heading.dart index cd9428f0..c7bc1f26 100644 --- a/lib/modules/settings/section_card_with_heading.dart +++ b/lib/modules/settings/section_card_with_heading.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart' show ListTileTheme, ListTileThemeData; -import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide Theme, ThemeData; import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; class SectionCardWithHeading extends StatelessWidget { @@ -35,7 +35,9 @@ class SectionCardWithHeading extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Text( heading, - style: context.theme.typography.large, + style: context.theme.typography.large.copyWith( + color: context.theme.colorScheme.foreground, + ), ), ), Padding( diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 0c6cfd69..4a10268b 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -1,8 +1,8 @@ -import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/tracks_view/track_view.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/track_presentation.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -23,43 +23,45 @@ class AlbumPage extends HookConsumerWidget { final favoriteAlbumsNotifier = ref.watch(favoriteAlbumsProvider.notifier); final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); - return InheritedTrackView( - collection: album, - image: album.images.asUrlString( - placeholder: ImagePlaceholder.albumArt, + return TrackPresentation( + options: TrackPresentationOptions( + collection: album, + image: album.images.asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + title: album.name!, + description: + "${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}", + tracks: tracks.asData?.value.items ?? [], + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + isLoading: tracks.isLoadingNextPage, + onFetchMore: () async { + await tracksNotifier.fetchMore(); + }, + onFetchAll: () async { + return tracksNotifier.fetchAll(); + }, + onRefresh: () async { + ref.invalidate(albumTracksProvider(album)); + }, + ), + routePath: "/album/${album.id}", + shareUrl: album.externalUrls?.spotify ?? + "https://open.spotify.com/album/${album.id}", + isLiked: isSavedAlbum.asData?.value ?? false, + owner: album.artists!.first.name, + onHeart: isSavedAlbum.asData?.value == null + ? null + : () async { + if (isSavedAlbum.asData!.value) { + await favoriteAlbumsNotifier.removeFavorites([album.id!]); + } else { + await favoriteAlbumsNotifier.addFavorites([album.id!]); + } + return null; + }, ), - title: album.name!, - description: - "${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}", - tracks: tracks.asData?.value.items ?? [], - pagination: PaginationProps( - hasNextPage: tracks.asData?.value.hasMore ?? false, - isLoading: tracks.isLoadingNextPage, - onFetchMore: () async { - await tracksNotifier.fetchMore(); - }, - onFetchAll: () async { - return tracksNotifier.fetchAll(); - }, - onRefresh: () async { - ref.invalidate(albumTracksProvider(album)); - }, - ), - routePath: "/album/${album.id}", - shareUrl: album.externalUrls?.spotify ?? - "https://open.spotify.com/album/${album.id}", - isLiked: isSavedAlbum.asData?.value ?? false, - onHeart: isSavedAlbum.asData?.value == null - ? null - : () async { - if (isSavedAlbum.asData!.value) { - await favoriteAlbumsNotifier.removeFavorites([album.id!]); - } else { - await favoriteAlbumsNotifier.addFavorites([album.id!]); - } - return null; - }, - child: const TrackView(), ); } } diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart index 478eac5e..8cfec3a8 100644 --- a/lib/pages/library/local_folder.dart +++ b/lib/pages/library/local_folder.dart @@ -17,7 +17,7 @@ import 'package:spotube/components/expandable_search/expandable_search.dart'; import 'package:spotube/components/fallbacks/not_found.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/sort_tracks_dropdown.dart'; +import 'package:spotube/components/track_presentation/sort_tracks_dropdown.dart'; import 'package:spotube/components/track_tile/track_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 942f46d5..3b4455d5 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -1,8 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/tracks_view/track_view.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/track_presentation.dart'; import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -20,28 +20,30 @@ class LikedPlaylistPage extends HookConsumerWidget { final likedTracks = ref.watch(likedTracksProvider); final tracks = likedTracks.asData?.value ?? []; - return InheritedTrackView( - collection: playlist, - image: "assets/liked-tracks.jpg", - pagination: PaginationProps( - hasNextPage: false, - isLoading: false, - onFetchMore: () {}, - onFetchAll: () async { - return tracks.toList(); - }, - onRefresh: () async { - ref.invalidate(likedTracksProvider); - }, + return TrackPresentation( + options: TrackPresentationOptions( + collection: playlist, + image: "assets/liked-tracks.jpg", + pagination: PaginationProps( + hasNextPage: false, + isLoading: false, + onFetchMore: () {}, + onFetchAll: () async { + return tracks.toList(); + }, + onRefresh: () async { + ref.invalidate(likedTracksProvider); + }, + ), + title: playlist.name!, + description: playlist.description, + tracks: tracks, + routePath: '/playlist/${playlist.id}', + isLiked: false, + shareUrl: null, + onHeart: null, + owner: playlist.owner?.displayName, ), - title: playlist.name!, - description: playlist.description, - tracks: tracks, - routePath: '/playlist/${playlist.id}', - isLiked: false, - shareUrl: "", - onHeart: null, - child: const TrackView(), ); } } diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index e1b33e98..da28c83c 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/tracks_view/track_view.dart'; -import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/track_presentation/presentation_props.dart'; +import 'package:spotube/components/track_presentation/track_presentation.dart'; +import 'package:spotube/components/track_presentation/use_is_user_playlist.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -45,49 +45,52 @@ class PlaylistPage extends HookConsumerWidget { final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); - return InheritedTrackView( - collection: playlist, - image: playlist.images.asUrlString( - placeholder: ImagePlaceholder.collection, - ), - pagination: PaginationProps( - hasNextPage: tracks.asData?.value.hasMore ?? false, - isLoading: tracks.isLoadingNextPage, - onFetchMore: tracksNotifier.fetchMore, - onRefresh: () async { - ref.invalidate(playlistTracksProvider(playlist.id!)); - }, - onFetchAll: () async { - return await tracksNotifier.fetchAll(); - }, - ), - title: playlist.name!, - description: playlist.description, - tracks: tracks.asData?.value.items ?? [], - routePath: '/playlist/${playlist.id}', - isLiked: isFavoritePlaylist.asData?.value ?? false, - shareUrl: playlist.externalUrls?.spotify ?? - "https://open.spotify.com/playlist/${playlist.id}", - onHeart: isFavoritePlaylist.asData?.value == null - ? null - : () async { - final confirmed = isUserPlaylist - ? await showPromptDialog( - context: context, - title: context.l10n.delete_playlist, - message: context.l10n.delete_playlist_confirmation, - ) - : true; - if (!confirmed) return null; + return TrackPresentation( + options: TrackPresentationOptions( + collection: playlist, + image: playlist.images.asUrlString( + placeholder: ImagePlaceholder.collection, + ), + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + isLoading: tracks.isLoadingNextPage, + onFetchMore: tracksNotifier.fetchMore, + onRefresh: () async { + ref.invalidate(playlistTracksProvider(playlist.id!)); + }, + onFetchAll: () async { + return await tracksNotifier.fetchAll(); + }, + ), + title: playlist.name!, + description: playlist.description, + owner: playlist.owner?.displayName, + ownerImage: playlist.owner?.images?.lastOrNull?.url, + tracks: tracks.asData?.value.items ?? [], + routePath: '/playlist/${playlist.id}', + isLiked: isFavoritePlaylist.asData?.value ?? false, + shareUrl: playlist.externalUrls?.spotify ?? + "https://open.spotify.com/playlist/${playlist.id}", + onHeart: isFavoritePlaylist.asData?.value == null + ? null + : () async { + final confirmed = isUserPlaylist + ? await showPromptDialog( + context: context, + title: context.l10n.delete_playlist, + message: context.l10n.delete_playlist_confirmation, + ) + : true; + if (!confirmed) return null; - if (isFavoritePlaylist.asData!.value) { - await favoritePlaylistsNotifier.removeFavorite(playlist); - } else { - await favoritePlaylistsNotifier.addFavorite(playlist); - } - return isUserPlaylist; - }, - child: const TrackView(), + if (isFavoritePlaylist.asData!.value) { + await favoritePlaylistsNotifier.removeFavorite(playlist); + } else { + await favoritePlaylistsNotifier.addFavorite(playlist); + } + return isUserPlaylist; + }, + ), ); } } diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 1407feb3..de152fbe 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -48,6 +48,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/pubspec.lock b/pubspec.lock index ff445cad..34306fef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -98,10 +98,10 @@ packages: dependency: "direct main" description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" audio_service: dependency: "direct main" description: @@ -203,10 +203,10 @@ packages: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" build: dependency: transitive description: @@ -347,10 +347,10 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" code_builder: dependency: transitive description: @@ -598,10 +598,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" ffi: dependency: transitive description: @@ -614,10 +614,10 @@ packages: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" file_picker: dependency: "direct main" description: @@ -1330,18 +1330,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -1642,10 +1642,10 @@ packages: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_drawing: dependency: transitive description: @@ -1786,10 +1786,10 @@ packages: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -1826,10 +1826,10 @@ packages: dependency: transitive description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.0.3" process_run: dependency: "direct dev" description: @@ -2257,10 +2257,10 @@ packages: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "0bd04f5bb74fcd6ff0606a888a30e917af9bd52820b178eaa464beb11dca84b6" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" stroke_text: dependency: "direct main" description: @@ -2553,10 +2553,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "14.3.1" watcher: dependency: transitive description: diff --git a/untranslated_messages.json b/untranslated_messages.json index 67bb4673..05b5aca3 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,105 +1,235 @@ { "ar": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "bn": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "ca": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "cs": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "de": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "es": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "eu": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "fa": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "fi": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "fr": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "hi": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "id": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "it": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "ja": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "ka": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "ko": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "ne": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "nl": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "pl": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "pt": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "ru": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "th": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "tr": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "uk": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "vi": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ], "zh": [ - "undo" + "undo", + "download_all", + "add_all_to_playlist", + "add_all_to_queue", + "play_all_next", + "pause" ] }