refactor: playlist and album pages

This commit is contained in:
Kingkor Roy Tirtho 2024-12-28 14:30:25 +06:00
parent ced85d3f0c
commit d53782da23
37 changed files with 1793 additions and 1516 deletions

View File

@ -1,3 +1,3 @@
{ {
"flutterSdkVersion": "3.27.1" "flutterSdkVersion": "3.28.0-0.1.pre"
} }

2
.fvmrc
View File

@ -1,4 +1,4 @@
{ {
"flutter": "3.27.1", "flutter": "3.28.0-0.1.pre",
"flavors": {} "flavors": {}
} }

View File

@ -28,5 +28,5 @@
"README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", "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": "${capture}.g.dart,${capture}.freezed.dart"
}, },
"dart.flutterSdkPath": ".fvm/versions/3.27.1" "dart.flutterSdkPath": ".fvm/versions/3.28.0-0.1.pre"
} }

View File

@ -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.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
@ -39,6 +39,8 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
final Offset offset; final Offset offset;
final ButtonVariance variance;
const AdaptivePopSheetList({ const AdaptivePopSheetList({
super.key, super.key,
required this.children, required this.children,
@ -49,6 +51,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
this.onSelected, this.onSelected,
required this.tooltip, required this.tooltip,
this.offset = Offset.zero, this.offset = Offset.zero,
this.variance = ButtonVariance.ghost,
}) : assert( }) : assert(
!(icon != null && child != null), !(icon != null && child != null),
'Either icon or child must be provided', 'Either icon or child must be provided',
@ -79,7 +82,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
}).toList(); }).toList();
if (mediaQuery.mdAndUp) { if (mediaQuery.mdAndUp) {
await showDropdown<T>( await showDropdown<T?>(
context: context, context: context,
rootOverlay: useRootNavigator, rootOverlay: useRootNavigator,
// heightConstraint: PopoverConstraint.anchorFixedSize, // heightConstraint: PopoverConstraint.anchorFixedSize,
@ -113,19 +116,21 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final data = childrenModified[index]; final data = childrenModified[index];
return ListTile( return Button(
dense: true,
leading: data.leading,
title: data.child,
enabled: data.enabled, enabled: data.enabled,
trailing: data.trailing, style: ButtonVariance.ghost.copyWith(
focusNode: data.focusNode, padding: (context, state, value) => const EdgeInsets.all(16),
onTap: () { ),
onPressed: () {
data.onPressed?.call(context); data.onPressed?.call(context);
if (data.autoClose) { if (data.autoClose) {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}, },
leading: data.leading,
trailing: data.trailing,
alignment: Alignment.centerLeft,
child: data.child,
); );
}, },
); );
@ -142,7 +147,8 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
tooltip: TooltipContainer( tooltip: TooltipContainer(
child: Text(tooltip), child: Text(tooltip),
), ),
child: IconButton.ghost( child: IconButton(
variance: variance,
icon: icon ?? const Icon(SpotubeIcons.moreVertical), icon: icon ?? const Icon(SpotubeIcons.moreVertical),
onPressed: () { onPressed: () {
final renderBox = context.findRenderObject() as RenderBox; final renderBox = context.findRenderObject() as RenderBox;
@ -167,7 +173,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
tooltip: TooltipContainer(child: Text(tooltip)), tooltip: TooltipContainer(child: Text(tooltip)),
child: Button( child: Button(
onPressed: () => showDropdownMenu(context, Offset.zero), onPressed: () => showDropdownMenu(context, Offset.zero),
style: const ButtonStyle.ghost(), style: variance,
child: IgnorePointer(child: child), child: IgnorePointer(child: child),
), ),
); );
@ -175,7 +181,8 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
return Tooltip( return Tooltip(
tooltip: TooltipContainer(child: Text(tooltip)), tooltip: TooltipContainer(child: Text(tooltip)),
child: IconButton.ghost( child: IconButton(
variance: variance,
icon: icon ?? const Icon(SpotubeIcons.moreVertical), icon: icon ?? const Icon(SpotubeIcons.moreVertical),
onPressed: () => showDropdownMenu(context, Offset.zero), onPressed: () => showDropdownMenu(context, Offset.zero),
), ),

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/heart_button/use_track_toggle_like.dart'; import 'package:spotube/components/heart_button/use_track_toggle_like.dart';
@ -13,12 +13,16 @@ class HeartButton extends HookConsumerWidget {
final IconData? icon; final IconData? icon;
final Color? color; final Color? color;
final String? tooltip; final String? tooltip;
final ButtonVariance variance;
final ButtonSize size;
const HeartButton({ const HeartButton({
required this.isLiked, required this.isLiked,
required this.onPressed, required this.onPressed,
this.color, this.color,
this.tooltip, this.tooltip,
this.icon, this.icon,
this.variance = ButtonVariance.ghost,
this.size = ButtonSize.normal,
super.key, super.key,
}); });
@ -28,8 +32,11 @@ class HeartButton extends HookConsumerWidget {
if (auth.asData?.value == null) return const SizedBox.shrink(); if (auth.asData?.value == null) return const SizedBox.shrink();
return IconButton( return Tooltip(
tooltip: tooltip, tooltip: TooltipContainer(child: Text(tooltip ?? "")),
child: IconButton(
variance: variance,
size: size,
icon: AnimatedSwitcher( icon: AnimatedSwitcher(
switchInCurve: Curves.fastOutSlowIn, switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn, switchOutCurve: Curves.fastOutSlowIn,
@ -50,6 +57,7 @@ class HeartButton extends HookConsumerWidget {
), ),
), ),
onPressed: onPressed, onPressed: onPressed,
),
); );
} }
} }

View File

@ -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<SortBy>(
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),
],
),
),
),
),
);
}
}

View File

@ -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<bool>(
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),
),
),
],
);
}
}

View File

@ -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();
},
);
},
);
}
}

View File

@ -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(),
],
),
),
],
),
);
});
}
}

View File

@ -1,6 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart' hide Page; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
class PaginationProps { class PaginationProps {
@ -38,31 +38,33 @@ class PaginationProps {
onRefresh.hashCode; onRefresh.hashCode;
} }
class InheritedTrackView extends InheritedWidget { class TrackPresentationOptions {
final Object collection; final Object collection;
final String title; final String title;
final String? description; final String? description;
final String? owner;
final String? ownerImage;
final String image; final String image;
final String routePath; final String routePath;
final List<Track> tracks; final List<Track> tracks;
final PaginationProps pagination; final PaginationProps pagination;
final bool isLiked; final bool isLiked;
final String shareUrl; final String? shareUrl;
// events // events
final FutureOr<bool?> Function()? onHeart; // if null heart button will hidden final FutureOr<bool?> Function()? onHeart; // if null heart button will hidden
const InheritedTrackView({ const TrackPresentationOptions({
super.key,
required super.child,
required this.collection, required this.collection,
required this.title, required this.title,
this.description, this.description,
this.owner,
this.ownerImage,
required this.image, required this.image,
required this.tracks, required this.tracks,
required this.pagination, required this.pagination,
required this.routePath, required this.routePath,
required this.shareUrl, this.shareUrl,
this.isLiked = false, this.isLiked = false,
this.onHeart, this.onHeart,
}) : assert(collection is AlbumSimple || collection is PlaylistSimple); }) : assert(collection is AlbumSimple || collection is PlaylistSimple);
@ -71,29 +73,36 @@ class InheritedTrackView extends InheritedWidget {
? (collection as AlbumSimple).id! ? (collection as AlbumSimple).id!
: (collection as PlaylistSimple).id!; : (collection as PlaylistSimple).id!;
@override static TrackPresentationOptions of(BuildContext context) {
bool updateShouldNotify(InheritedTrackView oldWidget) { return Data.of<TrackPresentationOptions>(context);
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 InheritedTrackView of(BuildContext context) { @override
final widget = operator ==(Object other) {
context.dependOnInheritedWidgetOfExactType<InheritedTrackView>(); return other is TrackPresentationOptions &&
if (widget == null) { other.collection == collection &&
throw Exception( other.title == title &&
'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]', other.description == description &&
); other.image == image &&
} other.routePath == routePath &&
return widget; 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;
} }

View File

@ -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<Track> selectedTracks;
final List<Track> presentationTracks;
final SortBy sortBy;
const PresentationState({
required this.selectedTracks,
required this.presentationTracks,
required this.sortBy,
});
PresentationState copyWith({
List<Track>? selectedTracks,
List<Track>? presentationTracks,
SortBy? sortBy,
}) {
return PresentationState(
selectedTracks: selectedTracks ?? this.selectedTracks,
presentationTracks: presentationTracks ?? this.presentationTracks,
sortBy: sortBy ?? this.sortBy,
);
}
}
class PresentationStateNotifier
extends AutoDisposeFamilyNotifier<PresentationState, Object> {
@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<Track> 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(),
);

View File

@ -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,
],
),
),
),
],
),
)
],
);
}
}

View File

@ -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<SortBy>(
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),
),
],
);
}
}

View File

@ -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<TrackPresentationOptions>.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),
],
),
),
),
);
}
}

View File

@ -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<void> Function() onShuffle,
Future<void> 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,
);
}

View File

@ -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<void> 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;
}

View File

@ -1,10 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/gestures.dart'; 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:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
@ -88,7 +89,11 @@ class TrackTile extends HookConsumerWidget {
}, },
child: HoverBuilder( child: HoverBuilder(
permanentState: isSelected || constrains.smAndDown ? true : null, permanentState: isSelected || constrains.smAndDown ? true : null,
builder: (context, isHovering) => ListTile( builder: (context, isHovering) => Material(
type: MaterialType.transparency,
child: ListTile(
selectedColor: theme.colorScheme.primary,
selectedTileColor: theme.colorScheme.primary.withOpacity(0.1),
selected: isSelected, selected: isSelected,
onTap: () async { onTap: () async {
try { try {
@ -103,44 +108,63 @@ class TrackTile extends HookConsumerWidget {
onLongPress: onLongPress, onLongPress: onLongPress,
enabled: !isBlackListed, enabled: !isBlackListed,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
tileColor: isBlackListed ? theme.colorScheme.errorContainer : null, tileColor: isBlackListed ? theme.colorScheme.destructive : null,
horizontalTitleGap: 12, horizontalTitleGap: 12,
leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, 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( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
...?leadingActions, ...?leadingActions,
if (index != null && onChanged == null && constrains.mdAndUp) AnimatedCrossFade(
SizedBox( 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),
),
secondChild: constrains.smAndDown
? const SizedBox(width: 16)
: SizedBox(
width: 50, width: 50,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6), padding:
const EdgeInsets.symmetric(horizontal: 6),
child: Text( child: Text(
'${(index ?? 0) + 1}', '${(index ?? 0) + 1}',
maxLines: 1, maxLines: 1,
style: theme.textTheme.bodySmall, style: theme.typography.small,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
) ),
else if (constrains.smAndDown)
const SizedBox(width: 16),
if (onChanged != null)
Checkbox(
value: selected,
onChanged: onChanged,
), ),
Stack( Stack(
children: [ children: [
ClipRRect( Container(
borderRadius: BorderRadius.circular(4), height: 40,
child: AspectRatio( width: 40,
aspectRatio: 1, decoration: BoxDecoration(
child: UniversalImage( borderRadius: theme.borderRadiusMd,
path: (track.album?.images).asUrlString( image: DecorationImage(
fit: BoxFit.cover,
image: UniversalImage.imageProvider(
(track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
), ),
fit: BoxFit.cover, ),
), ),
), ),
), ),
@ -148,7 +172,7 @@ class TrackTile extends HookConsumerWidget {
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4), borderRadius: theme.borderRadiusMd,
color: isHovering color: isHovering
? Colors.black.withOpacity(0.4) ? Colors.black.withOpacity(0.4)
: Colors.transparent, : Colors.transparent,
@ -157,9 +181,6 @@ class TrackTile extends HookConsumerWidget {
), ),
Positioned.fill( Positioned.fill(
child: Center( child: Center(
child: IconTheme(
data: theme.iconTheme
.copyWith(size: 26, color: Colors.white),
child: Skeleton.ignore( child: Skeleton.ignore(
child: Consumer( child: Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
@ -167,31 +188,37 @@ class TrackTile extends HookConsumerWidget {
ref.watch(queryingTrackInfoProvider); ref.watch(queryingTrackInfoProvider);
return AnimatedSwitcher( return AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: (isPlaying && isFetchingActiveTrack) || child: switch ((
isPlaying,
isFetchingActiveTrack,
isPlaying,
isHovering,
isLoading.value isLoading.value
? const SizedBox( )) {
(true, true, _, _, _) ||
(_, _, _, _, true) =>
const SizedBox(
width: 26, width: 26,
height: 26, height: 26,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 1.5, size: 1.5),
color: Colors.white,
), ),
) (_, _, true, _, _) => Icon(
: isPlaying
? Icon(
SpotubeIcons.pause, SpotubeIcons.pause,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
) ),
: !isHovering (_, _, _, true, _) => const Icon(
? const SizedBox.shrink() SpotubeIcons.play,
: const Icon(SpotubeIcons.play), color: Colors.white,
),
_ => const SizedBox.shrink(),
},
); );
}, },
), ),
), ),
), ),
), ),
),
], ],
), ),
], ],
@ -283,6 +310,7 @@ class TrackTile extends HookConsumerWidget {
), ),
), ),
), ),
),
); );
}); });
} }

View File

@ -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<Track> 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),
);
},
),
),
],
);
}
}

View File

@ -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<bool> 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),
],
);
},
);
}
}

View File

@ -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),
),
),
],
);
}
}

View File

@ -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),
],
),
),
],
),
),
],
),
),
),
),
),
),
),
);
},
),
);
}
}

View File

@ -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(),
);
},
);
},
),
],
);
}
}

View File

@ -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),
),
],
);
}
}

View File

@ -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(),
),
],
),
),
),
);
}
}

View File

@ -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<Track> tracks;
List<String> 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<Track> 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<TrackViewNotifier, List<Track>>((ref, tracks) {
return TrackViewNotifier(tracks);
});

View File

@ -402,5 +402,10 @@
"found_n_files": "Found {count} files", "found_n_files": "Found {count} files",
"export_cache_confirmation": "Do you want to export these files to", "export_cache_confirmation": "Do you want to export these files to",
"exported_n_out_of_m_files": "Exported {filesExported} out of {files} files", "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"
} }

View File

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' as material;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; import 'package:flutter_discord_rpc/flutter_discord_rpc.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@ -225,6 +226,9 @@ class Spotube extends HookConsumerWidget {
surfaceOpacity: .8, surfaceOpacity: .8,
surfaceBlur: 10, surfaceBlur: 10,
), ),
materialTheme: material.ThemeData(
splashFactory: material.NoSplash.splashFactory,
),
themeMode: themeMode, themeMode: themeMode,
shortcuts: { shortcuts: {
...WidgetsApp.defaultShortcuts.map((key, value) { ...WidgetsApp.defaultShortcuts.map((key, value) {

View File

@ -78,8 +78,8 @@ class Sidebar extends HookConsumerWidget {
isLabelVisible: tile.title == "Library" && downloadCount > 0, isLabelVisible: tile.title == "Library" && downloadCount > 0,
label: Text( label: Text(
downloadCount.toString(), downloadCount.toString(),
style: const TextStyle( style: TextStyle(
color: Colors.white, color: context.theme.colorScheme.primaryForeground,
fontSize: 10, fontSize: 10,
), ),
), ),

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart' show ListTileTheme, ListTileThemeData; 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'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
class SectionCardWithHeading extends StatelessWidget { class SectionCardWithHeading extends StatelessWidget {
@ -35,7 +35,9 @@ class SectionCardWithHeading extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text( child: Text(
heading, heading,
style: context.theme.typography.large, style: context.theme.typography.large.copyWith(
color: context.theme.colorScheme.foreground,
),
), ),
), ),
Padding( Padding(

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/tracks_view/track_view.dart'; import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/components/track_presentation/track_presentation.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
@ -23,7 +23,8 @@ class AlbumPage extends HookConsumerWidget {
final favoriteAlbumsNotifier = ref.watch(favoriteAlbumsProvider.notifier); final favoriteAlbumsNotifier = ref.watch(favoriteAlbumsProvider.notifier);
final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!));
return InheritedTrackView( return TrackPresentation(
options: TrackPresentationOptions(
collection: album, collection: album,
image: album.images.asUrlString( image: album.images.asUrlString(
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
@ -49,6 +50,7 @@ class AlbumPage extends HookConsumerWidget {
shareUrl: album.externalUrls?.spotify ?? shareUrl: album.externalUrls?.spotify ??
"https://open.spotify.com/album/${album.id}", "https://open.spotify.com/album/${album.id}",
isLiked: isSavedAlbum.asData?.value ?? false, isLiked: isSavedAlbum.asData?.value ?? false,
owner: album.artists!.first.name,
onHeart: isSavedAlbum.asData?.value == null onHeart: isSavedAlbum.asData?.value == null
? null ? null
: () async { : () async {
@ -59,7 +61,7 @@ class AlbumPage extends HookConsumerWidget {
} }
return null; return null;
}, },
child: const TrackView(), ),
); );
} }
} }

View File

@ -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/fallbacks/not_found.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/titlebar/titlebar.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/components/track_tile/track_tile.dart';
import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';

View File

@ -1,8 +1,8 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/tracks_view/track_view.dart'; import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/components/track_presentation/track_presentation.dart';
import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/pages/playlist/playlist.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
@ -20,7 +20,8 @@ class LikedPlaylistPage extends HookConsumerWidget {
final likedTracks = ref.watch(likedTracksProvider); final likedTracks = ref.watch(likedTracksProvider);
final tracks = likedTracks.asData?.value ?? <Track>[]; final tracks = likedTracks.asData?.value ?? <Track>[];
return InheritedTrackView( return TrackPresentation(
options: TrackPresentationOptions(
collection: playlist, collection: playlist,
image: "assets/liked-tracks.jpg", image: "assets/liked-tracks.jpg",
pagination: PaginationProps( pagination: PaginationProps(
@ -39,9 +40,10 @@ class LikedPlaylistPage extends HookConsumerWidget {
tracks: tracks, tracks: tracks,
routePath: '/playlist/${playlist.id}', routePath: '/playlist/${playlist.id}',
isLiked: false, isLiked: false,
shareUrl: "", shareUrl: null,
onHeart: null, onHeart: null,
child: const TrackView(), owner: playlist.owner?.displayName,
),
); );
} }
} }

View File

@ -3,9 +3,9 @@ import 'package:flutter/material.dart' hide Page;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/dialogs/prompt_dialog.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/track_presentation/presentation_props.dart';
import 'package:spotube/components/tracks_view/track_view.dart'; import 'package:spotube/components/track_presentation/track_presentation.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/components/track_presentation/use_is_user_playlist.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
@ -45,7 +45,8 @@ class PlaylistPage extends HookConsumerWidget {
final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!);
return InheritedTrackView( return TrackPresentation(
options: TrackPresentationOptions(
collection: playlist, collection: playlist,
image: playlist.images.asUrlString( image: playlist.images.asUrlString(
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
@ -63,6 +64,8 @@ class PlaylistPage extends HookConsumerWidget {
), ),
title: playlist.name!, title: playlist.name!,
description: playlist.description, description: playlist.description,
owner: playlist.owner?.displayName,
ownerImage: playlist.owner?.images?.lastOrNull?.url,
tracks: tracks.asData?.value.items ?? [], tracks: tracks.asData?.value.items ?? [],
routePath: '/playlist/${playlist.id}', routePath: '/playlist/${playlist.id}',
isLiked: isFavoritePlaylist.asData?.value ?? false, isLiked: isFavoritePlaylist.asData?.value ?? false,
@ -87,7 +90,7 @@ class PlaylistPage extends HookConsumerWidget {
} }
return isUserPlaylist; return isUserPlaylist;
}, },
child: const TrackView(), ),
); );
} }
} }

View File

@ -48,6 +48,7 @@
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES" debugDocumentVersioning = "YES"
debugServiceExtension = "internal" debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES"> allowLocationSimulation = "YES">
<BuildableProductRunnable <BuildableProductRunnable
runnableDebuggingMode = "0"> runnableDebuggingMode = "0">

View File

@ -98,10 +98,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: async name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.12.0"
audio_service: audio_service:
dependency: "direct main" dependency: "direct main"
description: description:
@ -203,10 +203,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: boolean_selector name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.2"
build: build:
dependency: transitive dependency: transitive
description: description:
@ -347,10 +347,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.2"
code_builder: code_builder:
dependency: transitive dependency: transitive
description: description:
@ -598,10 +598,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: fake_async name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.1" version: "1.3.2"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@ -614,10 +614,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file name: file
sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.0" version: "7.0.1"
file_picker: file_picker:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1330,18 +1330,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.7" version: "10.0.8"
leak_tracker_flutter_testing: leak_tracker_flutter_testing:
dependency: transitive dependency: transitive
description: description:
name: leak_tracker_flutter_testing name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.8" version: "3.0.9"
leak_tracker_testing: leak_tracker_testing:
dependency: transitive dependency: transitive
description: description:
@ -1642,10 +1642,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: path name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.0" version: "1.9.1"
path_drawing: path_drawing:
dependency: transitive dependency: transitive
description: description:
@ -1786,10 +1786,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: platform name: platform
sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.5" version: "3.1.6"
plugin_platform_interface: plugin_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1826,10 +1826,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: process name: process
sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.2" version: "5.0.3"
process_run: process_run:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -2257,10 +2257,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: string_scanner name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" sha256: "0bd04f5bb74fcd6ff0606a888a30e917af9bd52820b178eaa464beb11dca84b6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.4.0"
stroke_text: stroke_text:
dependency: "direct main" dependency: "direct main"
description: description:
@ -2553,10 +2553,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.0" version: "14.3.1"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:

View File

@ -1,105 +1,235 @@
{ {
"ar": [ "ar": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"bn": [ "bn": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"ca": [ "ca": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"cs": [ "cs": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"de": [ "de": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"es": [ "es": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"eu": [ "eu": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"fa": [ "fa": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"fi": [ "fi": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"fr": [ "fr": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"hi": [ "hi": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"id": [ "id": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"it": [ "it": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"ja": [ "ja": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"ka": [ "ka": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"ko": [ "ko": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"ne": [ "ne": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"nl": [ "nl": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"pl": [ "pl": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"pt": [ "pt": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"ru": [ "ru": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"th": [ "th": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"tr": [ "tr": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"uk": [ "uk": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"vi": [ "vi": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
], ],
"zh": [ "zh": [
"undo" "undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
] ]
} }