diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index ff7092e3..9239875e 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -130,4 +130,6 @@ abstract class SpotubeIcons { static const open = FeatherIcons.externalLink; static const radioChecked = Icons.radio_button_on_rounded; static const radioUnchecked = Icons.radio_button_off_rounded; + static const grid = FeatherIcons.grid; + static const list = FeatherIcons.list; } diff --git a/lib/components/adaptive/adaptive_pop_sheet_list.dart b/lib/components/adaptive/adaptive_pop_sheet_list.dart index 5345199e..fa72031e 100644 --- a/lib/components/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/adaptive/adaptive_pop_sheet_list.dart @@ -139,7 +139,9 @@ class AdaptivePopSheetList extends StatelessWidget { if (mediaQuery.mdAndUp) { return Tooltip( - tooltip: Text(tooltip ?? ''), + tooltip: TooltipContainer( + child: Text(tooltip ?? ''), + ), child: IconButton.ghost( icon: icon ?? const Icon(SpotubeIcons.moreVertical), onPressed: () { @@ -162,7 +164,7 @@ class AdaptivePopSheetList extends StatelessWidget { if (child != null) { return Tooltip( - tooltip: Text(tooltip ?? ''), + tooltip: TooltipContainer(child: Text(tooltip ?? '')), child: Button( onPressed: () => showDropdownMenu(context, Offset.zero), style: const ButtonStyle.ghost(), @@ -172,7 +174,7 @@ class AdaptivePopSheetList extends StatelessWidget { } return Tooltip( - tooltip: Text(tooltip ?? ''), + tooltip: TooltipContainer(child: Text(tooltip ?? '')), child: IconButton.ghost( icon: icon ?? const Icon(SpotubeIcons.moreVertical), onPressed: () => showDropdownMenu(context, Offset.zero), diff --git a/lib/components/button/back_button.dart b/lib/components/button/back_button.dart index 41b7d527..17b93cea 100644 --- a/lib/components/button/back_button.dart +++ b/lib/components/button/back_button.dart @@ -2,13 +2,19 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; class BackButton extends StatelessWidget { - const BackButton({super.key}); + final Color? color; + const BackButton({ + super.key, + this.color, + }); @override Widget build(BuildContext context) { return IconButton.ghost( size: const ButtonSize(.9), - icon: const Icon(SpotubeIcons.angleLeft), + icon: color != null + ? Icon(SpotubeIcons.angleLeft, color: color) + : const Icon(SpotubeIcons.angleLeft), onPressed: () => Navigator.of(context).pop(), ); } diff --git a/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index 31c6a37c..0ebebea7 100644 --- a/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -9,7 +9,6 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class HorizontalPlaybuttonCardView extends HookWidget { @@ -38,12 +37,7 @@ class HorizontalPlaybuttonCardView extends HookWidget { @override Widget build(BuildContext context) { final scrollController = useScrollController(); - final height = useBreakpointValue( - xs: 226, - sm: 226, - md: 236, - others: 266, - ); + final isArtist = items.every((s) => s is Artist); return Padding( padding: const EdgeInsets.all(8.0), @@ -64,7 +58,7 @@ class HorizontalPlaybuttonCardView extends HookWidget { ], ), SizedBox( - height: height, + height: isArtist ? 250 : 225, child: NotificationListener( // disable multiple scrollbar to use this onNotification: (notification) => true, @@ -88,7 +82,9 @@ class HorizontalPlaybuttonCardView extends HookWidget { onFetchData: onFetchMore, loadingBuilder: (context) => Skeletonizer( enabled: true, - child: AlbumCard(FakeData.albumSimple), + child: isArtist + ? ArtistCard(FakeData.artist) + : AlbumCard(FakeData.albumSimple), ), isLoading: isLoadingNextPage, hasReachedMax: !hasNextPage, @@ -100,11 +96,7 @@ class HorizontalPlaybuttonCardView extends HookWidget { PlaylistSimple() => PlaylistCard(item as PlaylistSimple), AlbumSimple() => AlbumCard(item as AlbumSimple), - Artist() => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12.0), - child: ArtistCard(item as Artist), - ), + Artist() => ArtistCard(item as Artist), _ => const SizedBox.shrink(), }; }), diff --git a/lib/components/playbutton_card.dart b/lib/components/playbutton_view/playbutton_card.dart similarity index 64% rename from lib/components/playbutton_card.dart rename to lib/components/playbutton_view/playbutton_card.dart index 31143ae8..849bab2a 100644 --- a/lib/components/playbutton_card.dart +++ b/lib/components/playbutton_view/playbutton_card.dart @@ -1,17 +1,15 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; - import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/string.dart'; import 'package:spotube/utils/platform.dart'; -class PlaybuttonCard extends HookWidget { +class PlaybuttonCard extends StatelessWidget { final void Function()? onTap; final void Function()? onPlaybuttonPressed; final void Function()? onAddToQueuePressed; final String? description; - final EdgeInsetsGeometry? margin; + final String imageUrl; final bool isPlaying; final bool isLoading; @@ -23,7 +21,6 @@ class PlaybuttonCard extends HookWidget { required this.isPlaying, required this.isLoading, required this.title, - this.margin, this.description, this.onPlaybuttonPressed, this.onAddToQueuePressed, @@ -56,15 +53,18 @@ class PlaybuttonCard extends HookWidget { AnimatedScale( curve: Curves.easeOutBack, duration: const Duration(milliseconds: 300), - scale: states.contains(WidgetState.hovered) || kIsMobile + scale: (states.contains(WidgetState.hovered) || + kIsMobile) && + !isLoading ? 1 : 0.7, child: AnimatedOpacity( duration: const Duration(milliseconds: 300), - opacity: - states.contains(WidgetState.hovered) || kIsMobile - ? 1 - : 0, + opacity: (states.contains(WidgetState.hovered) || + kIsMobile) && + !isLoading + ? 1 + : 0, child: IconButton.secondary( icon: const Icon(SpotubeIcons.queueAdd), onPressed: onAddToQueuePressed, @@ -76,17 +76,29 @@ class PlaybuttonCard extends HookWidget { AnimatedScale( curve: Curves.easeOutBack, duration: const Duration(milliseconds: 150), - scale: states.contains(WidgetState.hovered) || kIsMobile + scale: states.contains(WidgetState.hovered) || + kIsMobile || + isPlaying || + isLoading ? 1 : 0.7, child: AnimatedOpacity( duration: const Duration(milliseconds: 150), - opacity: - states.contains(WidgetState.hovered) || kIsMobile - ? 1 - : 0, + opacity: states.contains(WidgetState.hovered) || + kIsMobile || + isPlaying || + isLoading + ? 1 + : 0, child: IconButton.secondary( - icon: const Icon(SpotubeIcons.play), + icon: switch ((isLoading, isPlaying)) { + (true, _) => const CircularProgressIndicator( + size: 15, + ), + (false, false) => const Icon(SpotubeIcons.play), + (false, true) => const Icon(SpotubeIcons.pause) + }, + enabled: !isLoading, onPressed: onPlaybuttonPressed, size: ButtonSize.small, ), @@ -96,11 +108,23 @@ class PlaybuttonCard extends HookWidget { ), ); }, - ) + ), + if (isOwner) + const Positioned( + right: 5, + top: 5, + child: SecondaryBadge( + style: ButtonStyle.secondaryIcon( + shape: ButtonShape.circle, + size: ButtonSize.small, + ), + child: Icon(SpotubeIcons.user), + ), + ), ], ), title: Tooltip( - tooltip: Text(title), + tooltip: TooltipContainer(child: Text(title)), child: Text( title, maxLines: 1, diff --git a/lib/components/playbutton_view/playbutton_tile.dart b/lib/components/playbutton_view/playbutton_tile.dart new file mode 100644 index 00000000..3f9d89fe --- /dev/null +++ b/lib/components/playbutton_view/playbutton_tile.dart @@ -0,0 +1,92 @@ +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/image/universal_image.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/string.dart'; + +class PlaybuttonTile extends StatelessWidget { + final void Function()? onTap; + final void Function()? onPlaybuttonPressed; + final void Function()? onAddToQueuePressed; + final String? description; + + final String imageUrl; + final bool isPlaying; + final bool isLoading; + final String title; + final bool isOwner; + + const PlaybuttonTile({ + required this.imageUrl, + required this.isPlaying, + required this.isLoading, + required this.title, + this.description, + this.onPlaybuttonPressed, + this.onAddToQueuePressed, + this.onTap, + this.isOwner = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + final cleanDescription = description?.unescapeHtml().cleanHtml() ?? ""; + + return Button.ghost( + leading: ClipRRect( + borderRadius: context.theme.borderRadiusMd, + child: UniversalImage( + path: imageUrl, + width: 40, + height: 40, + fit: BoxFit.cover, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Tooltip( + tooltip: TooltipContainer(child: Text(context.l10n.add_to_queue)), + child: IconButton.outline( + icon: const Icon(SpotubeIcons.queueAdd), + onPressed: onAddToQueuePressed, + enabled: !isLoading, + ), + ), + const Gap(8), + Tooltip( + tooltip: TooltipContainer(child: Text(context.l10n.play)), + child: IconButton.secondary( + icon: switch ((isLoading, isPlaying)) { + (true, _) => const CircularProgressIndicator( + size: 22, + ), + (false, false) => const Icon(SpotubeIcons.play), + (false, true) => const Icon(SpotubeIcons.pause) + }, + onPressed: onPlaybuttonPressed, + enabled: !isLoading, + ), + ), + ], + ), + enabled: !isLoading, + onPressed: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title), + if (cleanDescription.isNotEmpty) + Text( + description!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).xSmall().muted(), + ], + ), + ); + } +} diff --git a/lib/components/playbutton_view/playbutton_view.dart b/lib/components/playbutton_view/playbutton_view.dart new file mode 100644 index 00000000..52cfb592 --- /dev/null +++ b/lib/components/playbutton_view/playbutton_view.dart @@ -0,0 +1,157 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/playbutton_view/playbutton_card.dart'; +import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; +import 'package:spotube/components/waypoint.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; + +const _dummyPlaybuttonCard = PlaybuttonCard( + imageUrl: 'https://placehold.co/150x150.png', + isLoading: false, + isPlaying: false, + title: "Playbutton", + description: "A really cool playbutton", + isOwner: false, +); + +const _dummyPlaybuttonTile = PlaybuttonTile( + imageUrl: 'https://placehold.co/150x150.png', + isLoading: false, + isPlaying: false, + title: "Playbutton", + description: "A really cool playbutton", + isOwner: false, +); + +/// A [PlaybuttonCard] grid/list view (selectable) sliver widget +/// with support for infinite scrolling +class PlaybuttonView extends StatelessWidget { + final int itemCount; + final Widget Function(BuildContext context, int index) gridItemBuilder; + final Widget Function(BuildContext context, int index) listItemBuilder; + final bool hasMore; + final bool isLoading; + final VoidCallback onRequestMore; + final ScrollController controller; + + const PlaybuttonView({ + super.key, + required this.itemCount, + required this.gridItemBuilder, + required this.listItemBuilder, + required this.hasMore, + required this.isLoading, + required this.onRequestMore, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return SliverLayoutBuilder( + builder: (context, constrains) => HookBuilder(builder: (context) { + final isGrid = useState(constrains.mdAndUp); + final hasUserInteracted = useRef(false); + + useEffect(() { + if (hasUserInteracted.value) return null; + if (isGrid.value != constrains.mdAndUp) { + isGrid.value = constrains.mdAndUp; + } + return null; + }, [constrains]); + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Toggle( + value: isGrid.value, + style: + const ButtonStyle.outline(density: ButtonDensity.icon), + onChanged: (value) { + isGrid.value = value; + hasUserInteracted.value = true; + }, + child: const Icon(SpotubeIcons.grid), + ), + const SizedBox(width: 8), + Toggle( + value: !isGrid.value, + style: + const ButtonStyle.outline(density: ButtonDensity.icon), + onChanged: (value) { + isGrid.value = !value; + hasUserInteracted.value = true; + }, + child: const Icon(SpotubeIcons.list), + ), + ], + ), + ), + const SliverGap(10), + // Toggle between grid and list view + switch ((isGrid.value, isLoading)) { + (true, _) => SliverGrid.builder( + itemCount: isLoading ? 6 : itemCount + 1, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 150, + mainAxisExtent: 225, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + if (isLoading) { + return const Skeletonizer( + enabled: true, + child: _dummyPlaybuttonCard, + ); + } + + if (index == itemCount) { + if (!hasMore) return const SizedBox.shrink(); + return Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: onRequestMore, + child: const Skeletonizer( + enabled: true, + child: _dummyPlaybuttonCard, + ), + ); + } + + return gridItemBuilder(context, index); + }, + ), + (false, true) => Skeletonizer.sliver( + enabled: true, + child: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _dummyPlaybuttonTile, + childCount: 6, + ), + ), + ), + (false, false) => SliverInfiniteList( + itemCount: itemCount, + loadingBuilder: (context) => const Skeletonizer( + enabled: true, + child: _dummyPlaybuttonTile, + ), + itemBuilder: listItemBuilder, + onFetchData: onRequestMore, + hasReachedMax: !hasMore, + isLoading: isLoading, + ), + } + ], + ); + }), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f949480e..5b9e5183 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -401,5 +401,6 @@ "export_cache_files": "Export Cached Files", "found_n_files": "Found {count} files", "export_cache_confirmation": "Do you want to export these files to", - "exported_n_out_of_m_files": "Exported {filesExported} out of {files} files" + "exported_n_out_of_m_files": "Exported {filesExported} out of {files} files", + "undo": "Undo" } \ No newline at end of file diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index dd914fad..86935698 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -1,9 +1,10 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/playbutton_card.dart'; +import 'package:spotube/components/playbutton_view/playbutton_card.dart'; +import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; @@ -24,10 +25,16 @@ extension FormattedAlbumType on AlbumType { class AlbumCard extends HookConsumerWidget { final AlbumSimple album; + final bool _isTile; const AlbumCard( this.album, { super.key, - }); + }) : _isTile = false; + + const AlbumCard.tile( + this.album, { + super.key, + }) : _isTile = true; @override Widget build(BuildContext context, ref) { @@ -45,8 +52,6 @@ class AlbumCard extends HookConsumerWidget { final updating = useState(false); - final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); - Future> fetchAllTrack() async { if (album.tracks != null && album.tracks!.isNotEmpty) { return album.tracks!.map((track) => track.asTrack(album)).toList(); @@ -55,88 +60,116 @@ class AlbumCard extends HookConsumerWidget { return ref.read(albumTracksProvider(album).notifier).fetchAll(); } - return PlaybuttonCard( - imageUrl: album.images.asUrlString( - placeholder: ImagePlaceholder.collection, - ), - margin: const EdgeInsets.symmetric(horizontal: 10), - isPlaying: isPlaylistPlaying, - isLoading: - (isPlaylistPlaying && isFetchingActiveTrack) || updating.value, - title: album.name!, - description: - "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", - onTap: () { - ServiceUtils.pushNamed( - context, - AlbumPage.name, - pathParameters: { - "id": album.id!, - }, - extra: album, + var imageUrl = album.images.asUrlString( + placeholder: ImagePlaceholder.collection, + ); + var isLoading = + (isPlaylistPlaying && isFetchingActiveTrack) || updating.value; + var description = + "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}"; + + void onTap() { + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: { + "id": album.id!, + }, + extra: album, + ); + } + + void onPlaybuttonPressed() async { + updating.value = true; + try { + if (isPlaylistPlaying) { + return playing ? audioPlayer.pause() : audioPlayer.resume(); + } + + final fetchedTracks = await fetchAllTrack(); + + if (fetchedTracks.isEmpty || !context.mounted) return; + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData.album( + tracks: fetchedTracks, + collection: album, + ), ); - }, - onPlaybuttonPressed: () async { - updating.value = true; - try { - if (isPlaylistPlaying) { - return playing ? audioPlayer.pause() : audioPlayer.resume(); - } + } else { + await playlistNotifier.load(fetchedTracks, autoPlay: true); + playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); + } + } finally { + updating.value = false; + } + } - final fetchedTracks = await fetchAllTrack(); + void onAddToQueuePressed() async { + if (isPlaylistPlaying) { + return; + } - if (fetchedTracks.isEmpty || !context.mounted) return; + updating.value = true; + try { + final fetchedTracks = await fetchAllTrack(); - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - await remotePlayback.load( - WebSocketLoadEventData.album( - tracks: fetchedTracks, - collection: album, + if (fetchedTracks.isEmpty) return; + playlistNotifier.addTracks(fetchedTracks); + playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); + if (context.mounted) { + showToast( + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + content: Text( + context.l10n.added_to_queue(fetchedTracks.length), + ), + trailing: Button.outline( + child: Text(context.l10n.undo), + onPressed: () { + playlistNotifier + .removeTracks(fetchedTracks.map((e) => e.id!)); + }, + ), ), ); - } else { - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(album.id!); - historyNotifier.addAlbums([album]); - } - } finally { - updating.value = false; - } - }, - onAddToQueuePressed: () async { - if (isPlaylistPlaying) { - return; - } + }, + ); + } + } finally { + updating.value = false; + } + } - updating.value = true; - try { - final fetchedTracks = await fetchAllTrack(); + if (_isTile) { + return PlaybuttonTile( + imageUrl: imageUrl, + isPlaying: isPlaylistPlaying, + isLoading: isLoading, + title: album.name!, + description: description, + onTap: onTap, + onPlaybuttonPressed: onPlaybuttonPressed, + onAddToQueuePressed: onAddToQueuePressed, + ); + } - if (fetchedTracks.isEmpty) return; - playlistNotifier.addTracks(fetchedTracks); - playlistNotifier.addCollection(album.id!); - historyNotifier.addAlbums([album]); - if (context.mounted) { - final snackbar = SnackBar( - content: Text( - context.l10n.added_to_queue(fetchedTracks.length), - ), - action: SnackBarAction( - label: "Undo", - onPressed: () { - playlistNotifier - .removeTracks(fetchedTracks.map((e) => e.id!)); - }, - ), - ); - - scaffoldMessenger?.showSnackBar(snackbar); - } - } finally { - updating.value = false; - } - }); + return PlaybuttonCard( + imageUrl: imageUrl, + isPlaying: isPlaylistPlaying, + isLoading: isLoading, + title: album.name!, + description: description, + onTap: onTap, + onPlaybuttonPressed: onPlaybuttonPressed, + onAddToQueuePressed: onAddToQueuePressed, + ); } } diff --git a/lib/modules/library/user_albums.dart b/lib/modules/library/user_albums.dart index 535381fc..a388c0ad 100644 --- a/lib/modules/library/user_albums.dart +++ b/lib/modules/library/user_albums.dart @@ -4,14 +4,12 @@ import 'package:collection/collection.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/playbutton_view/playbutton_view.dart'; import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -78,39 +76,17 @@ class UserAlbums extends HookConsumerWidget { const SliverGap(10), SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 8), - sliver: SliverGrid.builder( - itemCount: albums.isEmpty ? 6 : albums.length + 1, - gridDelegate: - const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 150, - mainAxisExtent: 225, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + sliver: PlaybuttonView( + controller: controller, + itemCount: albums.length, + hasMore: albumsQuery.asData?.value.hasMore == true, + isLoading: albumsQuery.isLoading, + onRequestMore: albumsQueryNotifier.fetchMore, + gridItemBuilder: (context, index) => AlbumCard( + albums[index], ), - itemBuilder: (context, index) { - if (albums.isNotEmpty && index == albums.length) { - if (albumsQuery.asData?.value.hasMore != true) { - return const SizedBox.shrink(); - } - - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: albumsQueryNotifier.fetchMore, - child: Skeletonizer( - enabled: true, - child: AlbumCard(FakeData.albumSimple), - ), - ); - } - - return Skeletonizer( - enabled: albumsQuery.isLoading, - child: AlbumCard( - albums.elementAtOrNull(index) ?? FakeData.albumSimple, - ), - ); - }, + listItemBuilder: (context, index) => + AlbumCard.tile(albums[index]), ), ), ], diff --git a/lib/modules/library/user_playlists.dart b/lib/modules/library/user_playlists.dart index 0f307894..2a2d65e0 100644 --- a/lib/modules/library/user_playlists.dart +++ b/lib/modules/library/user_playlists.dart @@ -5,16 +5,14 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide Image; import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; -import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/playbutton_view/playbutton_view.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/provider/authentication/authentication.dart'; @@ -127,35 +125,17 @@ class UserPlaylists extends HookConsumerWidget { const SliverGap(10), SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 8), - sliver: SliverGrid.builder( - itemCount: playlists.isEmpty ? 6 : playlists.length + 1, - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 150, - mainAxisExtent: 225, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - ), - itemBuilder: (context, index) { - if (playlists.isNotEmpty && index == playlists.length) { - if (playlistsQuery.asData?.value.hasMore != true) { - return const SizedBox.shrink(); - } - - return Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: playlistsQueryNotifier.fetchMore, - child: Skeletonizer( - enabled: true, - child: PlaylistCard(FakeData.playlistSimple), - ), - ); - } - - return PlaylistCard( - playlists.elementAtOrNull(index) ?? - FakeData.playlistSimple, - ); + sliver: PlaybuttonView( + controller: controller, + hasMore: playlistsQuery.asData?.value.hasMore == true, + isLoading: playlistsQuery.isLoading, + onRequestMore: playlistsQueryNotifier.fetchMore, + itemCount: playlists.length, + gridItemBuilder: (context, index) { + return PlaylistCard(playlists[index]); + }, + listItemBuilder: (context, index) { + return PlaylistCard.tile(playlists[index]); }, ), ), diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart index dbdfa11b..5b469510 100644 --- a/lib/modules/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -79,7 +79,7 @@ class PlayerActions extends HookConsumerWidget { children: [ if (showQueue) Tooltip( - tooltip: Text(context.l10n.queue), + tooltip: TooltipContainer(child: Text(context.l10n.queue)), child: IconButton.ghost( icon: const Icon(SpotubeIcons.queue), enabled: playlist.activeTrack != null, @@ -115,7 +115,8 @@ class PlayerActions extends HookConsumerWidget { ), if (!isLocalTrack) Tooltip( - tooltip: Text(context.l10n.alternative_track_sources), + tooltip: TooltipContainer( + child: Text(context.l10n.alternative_track_sources)), child: IconButton.ghost( icon: const Icon(SpotubeIcons.alternativeRoute), onPressed: playlist.activeTrack != null @@ -147,7 +148,8 @@ class PlayerActions extends HookConsumerWidget { ) else Tooltip( - tooltip: Text(context.l10n.download_track), + tooltip: + TooltipContainer(child: Text(context.l10n.download_track)), child: IconButton.ghost( icon: Icon( isDownloaded ? SpotubeIcons.done : SpotubeIcons.download, diff --git a/lib/modules/player/player_controls.dart b/lib/modules/player/player_controls.dart index 0b3f5c2b..52c40b35 100644 --- a/lib/modules/player/player_controls.dart +++ b/lib/modules/player/player_controls.dart @@ -84,7 +84,8 @@ class PlayerControls extends HookConsumerWidget { return Column( children: [ Tooltip( - tooltip: Text(context.l10n.slide_to_seek), + tooltip: TooltipContainer( + child: Text(context.l10n.slide_to_seek)), child: Slider( value: SliderValue.single(progress.value.toDouble()), @@ -132,10 +133,12 @@ class PlayerControls extends HookConsumerWidget { final shuffled = ref .watch(audioPlayerProvider.select((s) => s.shuffled)); return Tooltip( - tooltip: Text( - shuffled - ? context.l10n.unshuffle_playlist - : context.l10n.shuffle_playlist, + tooltip: TooltipContainer( + child: Text( + shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + ), ), child: IconButton( icon: const Icon(SpotubeIcons.shuffle), @@ -155,7 +158,8 @@ class PlayerControls extends HookConsumerWidget { ); }), Tooltip( - tooltip: Text(context.l10n.previous_track), + tooltip: TooltipContainer( + child: Text(context.l10n.previous_track)), child: IconButton.ghost( enabled: !isFetchingActiveTrack, icon: const Icon(SpotubeIcons.skipBack), @@ -163,10 +167,12 @@ class PlayerControls extends HookConsumerWidget { ), ), Tooltip( - tooltip: Text( - playing - ? context.l10n.pause_playback - : context.l10n.resume_playback, + tooltip: TooltipContainer( + child: Text( + playing + ? context.l10n.pause_playback + : context.l10n.resume_playback, + ), ), child: IconButton.primary( shape: ButtonShape.circle, @@ -188,7 +194,8 @@ class PlayerControls extends HookConsumerWidget { ), ), Tooltip( - tooltip: Text(context.l10n.next_track), + tooltip: + TooltipContainer(child: Text(context.l10n.next_track)), child: IconButton.ghost( icon: const Icon(SpotubeIcons.skipForward), onPressed: @@ -200,12 +207,14 @@ class PlayerControls extends HookConsumerWidget { .watch(audioPlayerProvider.select((s) => s.loopMode)); return Tooltip( - tooltip: Text( - loopMode == PlaylistMode.single - ? context.l10n.loop_track - : loopMode == PlaylistMode.loop - ? context.l10n.repeat_playlist - : "", + tooltip: TooltipContainer( + child: Text( + loopMode == PlaylistMode.single + ? context.l10n.loop_track + : loopMode == PlaylistMode.loop + ? context.l10n.repeat_playlist + : "", + ), ), child: IconButton( icon: Icon( diff --git a/lib/modules/player/player_queue.dart b/lib/modules/player/player_queue.dart index 0186d974..58442666 100644 --- a/lib/modules/player/player_queue.dart +++ b/lib/modules/player/player_queue.dart @@ -160,7 +160,8 @@ class PlayerQueue extends HookConsumerWidget { if (mediaQuery.mdAndUp || !isSearching.value) ...[ const SizedBox(width: 10), Tooltip( - tooltip: Text(context.l10n.clear_all), + tooltip: TooltipContainer( + child: Text(context.l10n.clear_all)), child: IconButton.outline( icon: const Icon(SpotubeIcons.playlistRemove), onPressed: () { diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index df683a80..945f3571 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -1,9 +1,10 @@ -import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/playbutton_card.dart'; +import 'package:spotube/components/playbutton_view/playbutton_card.dart'; +import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/connect/connect.dart'; @@ -18,10 +19,18 @@ import 'package:spotube/utils/service_utils.dart'; class PlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; + final bool _isTile; + const PlaylistCard( this.playlist, { super.key, - }); + }) : _isTile = false; + + const PlaylistCard.tile( + this.playlist, { + super.key, + }) : _isTile = true; + @override Widget build(BuildContext context, ref) { final playlistQueue = ref.watch(audioPlayerProvider); @@ -60,96 +69,128 @@ class PlaylistCard extends HookConsumerWidget { return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); } - return PlaybuttonCard( - margin: const EdgeInsets.symmetric(horizontal: 10), - title: playlist.name!, - description: playlist.description, - imageUrl: playlist.images.asUrlString( - placeholder: ImagePlaceholder.collection, - ), - isPlaying: isPlaylistPlaying, - isLoading: (isPlaylistPlaying && isFetchingActiveTrack) || updating.value, - isOwner: playlist.owner?.id == me.asData?.value.id && - me.asData?.value.id != null, - onTap: () { - ServiceUtils.pushNamed( - context, - PlaylistPage.name, - pathParameters: { - "id": playlist.id!, - }, - extra: playlist, - ); - }, - onPlaybuttonPressed: () async { - try { - updating.value = true; - if (isPlaylistPlaying && playing) { - return audioPlayer.pause(); - } else if (isPlaylistPlaying && !playing) { - return audioPlayer.resume(); - } + void onTap() { + ServiceUtils.pushNamed( + context, + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, + extra: playlist, + ); + } - final fetchedInitialTracks = await fetchInitialTracks(); - - if (fetchedInitialTracks.isEmpty || !context.mounted) return; - - final isRemoteDevice = await showSelectDeviceDialog(context, ref); - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - final allTracks = await fetchAllTracks(); - await remotePlayback.load( - WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: playlist, - ), - ); - } else { - await playlistNotifier.load(fetchedInitialTracks, autoPlay: true); - playlistNotifier.addCollection(playlist.id!); - historyNotifier.addPlaylists([playlist]); - - final allTracks = await fetchAllTracks(); - - await playlistNotifier - .addTracks(allTracks.sublist(fetchedInitialTracks.length)); - } - } finally { - if (context.mounted) { - updating.value = false; - } - } - }, - onAddToQueuePressed: () async { + void onPlaybuttonPressed() async { + try { updating.value = true; - try { - if (isPlaylistPlaying) return; + if (isPlaylistPlaying && playing) { + return audioPlayer.pause(); + } else if (isPlaylistPlaying && !playing) { + return audioPlayer.resume(); + } - final fetchedInitialTracks = await fetchAllTracks(); + final fetchedInitialTracks = await fetchInitialTracks(); - if (fetchedInitialTracks.isEmpty) return; + if (fetchedInitialTracks.isEmpty || !context.mounted) return; - playlistNotifier.addTracks(fetchedInitialTracks); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final allTracks = await fetchAllTracks(); + await remotePlayback.load( + WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: playlist, + ), + ); + } else { + await playlistNotifier.load(fetchedInitialTracks, autoPlay: true); playlistNotifier.addCollection(playlist.id!); historyNotifier.addPlaylists([playlist]); - if (context.mounted) { - final snackbar = SnackBar( - content: Text(context.l10n - .added_num_tracks_to_queue(fetchedInitialTracks.length)), - action: SnackBarAction( - label: "Undo", - onPressed: () { - playlistNotifier - .removeTracks(fetchedInitialTracks.map((e) => e.id!)); - }, - ), - ); - ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar); - } - } finally { + + final allTracks = await fetchAllTracks(); + + await playlistNotifier + .addTracks(allTracks.sublist(fetchedInitialTracks.length)); + } + } finally { + if (context.mounted) { updating.value = false; } - }, + } + } + + void onAddToQueuePressed() async { + updating.value = true; + try { + if (isPlaylistPlaying) return; + + final fetchedInitialTracks = await fetchAllTracks(); + + if (fetchedInitialTracks.isEmpty) return; + + playlistNotifier.addTracks(fetchedInitialTracks); + playlistNotifier.addCollection(playlist.id!); + historyNotifier.addPlaylists([playlist]); + if (context.mounted) { + showToast( + context: context, + builder: (context, overlay) { + return SurfaceCard( + child: Basic( + content: Text( + context.l10n + .added_num_tracks_to_queue(fetchedInitialTracks.length), + ), + trailing: Button.outline( + child: Text(context.l10n.undo), + onPressed: () { + playlistNotifier + .removeTracks(fetchedInitialTracks.map((e) => e.id!)); + }, + ), + ), + ); + }, + ); + } + } finally { + updating.value = false; + } + } + + final imageUrl = playlist.images.asUrlString( + placeholder: ImagePlaceholder.collection, + ); + final isLoading = + (isPlaylistPlaying && isFetchingActiveTrack) || updating.value; + final isOwner = playlist.owner?.id == me.asData?.value.id && + me.asData?.value.id != null; + + if (_isTile) { + return PlaybuttonTile( + title: playlist.name!, + description: playlist.description, + imageUrl: imageUrl, + isPlaying: isPlaylistPlaying, + isLoading: isLoading, + isOwner: isOwner, + onTap: onTap, + onPlaybuttonPressed: onPlaybuttonPressed, + onAddToQueuePressed: onAddToQueuePressed, + ); + } + + return PlaybuttonCard( + title: playlist.name!, + description: playlist.description, + imageUrl: imageUrl, + isPlaying: isPlaylistPlaying, + isLoading: isLoading, + isOwner: isOwner, + onTap: onTap, + onPlaybuttonPressed: onPlaybuttonPressed, + onAddToQueuePressed: onAddToQueuePressed, ); } } diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart index 8a22cc7a..fc581377 100644 --- a/lib/modules/root/bottom_player.dart +++ b/lib/modules/root/bottom_player.dart @@ -74,7 +74,8 @@ class BottomPlayer extends HookConsumerWidget { PlayerActions( extraActions: [ Tooltip( - tooltip: Text(context.l10n.mini_player), + tooltip: + TooltipContainer(child: Text(context.l10n.mini_player)), child: IconButton( variance: ButtonVariance.ghost, icon: const Icon(SpotubeIcons.miniPlayer), diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart index 0249d865..38d0887c 100644 --- a/lib/pages/home/feed/feed_section.dart +++ b/lib/pages/home/feed/feed_section.dart @@ -1,12 +1,13 @@ -import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.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/playbutton_view/playbutton_view.dart'; import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/views/home_section.dart'; class HomeFeedSectionPage extends HookConsumerWidget { @@ -19,47 +20,72 @@ class HomeFeedSectionPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final homeFeedSection = ref.watch(homeSectionViewProvider(sectionUri)); final section = homeFeedSection.asData?.value ?? FakeData.feedSection; + final controller = useScrollController(); + final isArtist = section.items.every((item) => item.artist != null); return Skeletonizer( enabled: homeFeedSection.isLoading, child: Scaffold( - appBar: TitleBar( - title: Text(section.title ?? ""), - automaticallyImplyLeading: true, - ), - body: CustomScrollView( - slivers: [ - SliverLayoutBuilder( - builder: (context, constrains) { - return SliverGrid.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + headers: [ + TitleBar( + title: Text(section.title ?? ""), + automaticallyImplyLeading: true, + ) + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: CustomScrollView( + controller: controller, + slivers: [ + if (isArtist) + SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 200, - mainAxisExtent: constrains.smAndDown ? 225 : 250, + mainAxisExtent: 250, crossAxisSpacing: 8, mainAxisSpacing: 8, ), itemCount: section.items.length, itemBuilder: (context, index) { final item = section.items[index]; - + return ArtistCard(item.artist!.asArtist); + }, + ) + else + PlaybuttonView( + controller: controller, + itemCount: section.items.length, + hasMore: false, + isLoading: false, + onRequestMore: () => {}, + listItemBuilder: (context, index) { + final item = section.items[index]; + if (item.album != null) { + return AlbumCard.tile(item.album!.asAlbum); + } + if (item.playlist != null) { + return PlaylistCard.tile(item.playlist!.asPlaylist); + } + return const SizedBox.shrink(); + }, + gridItemBuilder: (context, index) { + final item = section.items[index]; if (item.album != null) { return AlbumCard(item.album!.asAlbum); - } else if (item.artist != null) { - return ArtistCard(item.artist!.asArtist); - } else if (item.playlist != null) { + } + if (item.playlist != null) { return PlaylistCard(item.playlist!.asPlaylist); } - return const SizedBox(); + return const SizedBox.shrink(); }, - ); - }, - ), - const SliverToBoxAdapter( - child: SafeArea( - child: SizedBox(), + ), + const SliverToBoxAdapter( + child: SafeArea( + child: SizedBox(), + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index ec478617..ebfc4450 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -1,19 +1,20 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' show CollapseMode, FlexibleSpaceBar; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; + import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; + import 'package:spotify/spotify.dart' hide Offset; -import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/button/back_button.dart'; +import 'package:spotube/components/playbutton_view/playbutton_view.dart'; import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:collection/collection.dart'; import 'package:spotube/utils/platform.dart'; class GenrePlaylistsPage extends HookConsumerWidget { @@ -39,123 +40,93 @@ class GenrePlaylistsPage extends HookConsumerWidget { ); return Scaffold( - appBar: kIsDesktop - ? const TitleBar( - leading: [BackButton(color: Colors.white)], - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - ) - : null, - extendBodyBehindAppBar: true, - body: DecoratedBox( + headers: [ + if (kIsDesktop) + const TitleBar( + leading: [ + BackButton(), + ], + backgroundColor: Colors.transparent, + surfaceOpacity: 0, + surfaceBlur: 0, + ) + ], + floatingHeader: true, + child: DecoratedBox( decoration: BoxDecoration( image: DecorationImage( image: UniversalImage.imageProvider(category.icons!.first.url!), alignment: Alignment.topCenter, fit: BoxFit.cover, - colorFilter: ColorFilter.mode( - Colors.black.withOpacity(0.5), - BlendMode.darken, - ), repeat: ImageRepeat.noRepeat, matchTextDirection: true, ), ), - child: CustomScrollView( - controller: scrollController, - slivers: [ - SliverAppBar( - automaticallyImplyLeading: kIsMobile, - expandedHeight: mediaQuery.mdAndDown ? 200 : 150, - title: const Text(""), - backgroundColor: Colors.transparent, - flexibleSpace: FlexibleSpaceBar( - centerTitle: kIsDesktop, - title: Text( - category.name!, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.white, - letterSpacing: 3, - shadows: [ - const Shadow( - offset: Offset(-1.5, -1.5), - color: Colors.black54, - ), - const Shadow( - offset: Offset(1.5, -1.5), - color: Colors.black54, - ), - const Shadow( - offset: Offset(1.5, 1.5), - color: Colors.black54, - ), - const Shadow( - offset: Offset(-1.5, 1.5), - color: Colors.black54, - ), - ], + child: SurfaceCard( + borderRadius: BorderRadius.zero, + padding: EdgeInsets.zero, + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverAppBar( + automaticallyImplyLeading: false, + leading: kIsMobile ? const BackButton() : null, + expandedHeight: mediaQuery.mdAndDown ? 200 : 150, + title: const Text(""), + backgroundColor: Colors.transparent, + flexibleSpace: FlexibleSpaceBar( + centerTitle: kIsDesktop, + title: Text( + category.name!, + style: context.theme.typography.h3.copyWith( + color: Colors.white, + letterSpacing: 3, + shadows: [ + Shadow( + offset: const Offset(-1.5, -1.5), + color: Colors.black.withAlpha(138), + ), + Shadow( + offset: const Offset(1.5, -1.5), + color: Colors.black.withAlpha(138), + ), + Shadow( + offset: const Offset(1.5, 1.5), + color: Colors.black.withAlpha(138), + ), + Shadow( + offset: const Offset(-1.5, 1.5), + color: Colors.black.withAlpha(138), + ), + ], + ), + ), + collapseMode: CollapseMode.parallax, + ), + ), + const SliverGap(20), + SliverSafeArea( + top: false, + sliver: SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: mediaQuery.mdAndDown ? 12 : 24, + ), + sliver: PlaybuttonView( + controller: scrollController, + itemCount: playlists.asData?.value.items.length ?? 0, + isLoading: playlists.isLoading, + hasMore: playlists.asData?.value.hasMore == true, + onRequestMore: playlistsNotifier.fetchMore, + listItemBuilder: (context, index) => + PlaylistCard.tile(playlists.asData!.value.items[index]), + gridItemBuilder: (context, index) => + PlaylistCard(playlists.asData!.value.items[index]), ), ), - collapseMode: CollapseMode.parallax, ), - ), - const SliverGap(20), - SliverSafeArea( - top: false, - sliver: SliverPadding( - padding: EdgeInsets.symmetric( - horizontal: mediaQuery.mdAndDown ? 12 : 24, - ), - sliver: playlists.asData?.value.items.isNotEmpty != true - ? Skeletonizer.sliver( - child: SliverToBoxAdapter( - child: Wrap( - spacing: 12, - runSpacing: 12, - children: List.generate( - 6, - (index) => PlaylistCard(FakeData.playlist), - ), - ), - ), - ) - : SliverGrid.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 190, - mainAxisExtent: mediaQuery.mdAndDown ? 225 : 250, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - ), - itemCount: - (playlists.asData?.value.items.length ?? 0) + 1, - itemBuilder: (context, index) { - final playlist = playlists.asData?.value.items - .elementAtOrNull(index); - - if (playlist == null) { - if (playlists.asData?.value.hasMore == false) { - return const SizedBox.shrink(); - } - return Skeletonizer( - enabled: true, - child: Waypoint( - controller: scrollController, - isGrid: true, - onTouchEdge: playlistsNotifier.fetchMore, - child: PlaylistCard(FakeData.playlist), - ), - ); - } - - return Skeleton.keep( - child: PlaylistCard(playlist), - ); - }, - ), - ), - ), - const SliverGap(20), - ], + const SliverGap(20), + ], + ), ), ), ); diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfee..67bb4673 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,105 @@ -{} \ No newline at end of file +{ + "ar": [ + "undo" + ], + + "bn": [ + "undo" + ], + + "ca": [ + "undo" + ], + + "cs": [ + "undo" + ], + + "de": [ + "undo" + ], + + "es": [ + "undo" + ], + + "eu": [ + "undo" + ], + + "fa": [ + "undo" + ], + + "fi": [ + "undo" + ], + + "fr": [ + "undo" + ], + + "hi": [ + "undo" + ], + + "id": [ + "undo" + ], + + "it": [ + "undo" + ], + + "ja": [ + "undo" + ], + + "ka": [ + "undo" + ], + + "ko": [ + "undo" + ], + + "ne": [ + "undo" + ], + + "nl": [ + "undo" + ], + + "pl": [ + "undo" + ], + + "pt": [ + "undo" + ], + + "ru": [ + "undo" + ], + + "th": [ + "undo" + ], + + "tr": [ + "undo" + ], + + "uk": [ + "undo" + ], + + "vi": [ + "undo" + ], + + "zh": [ + "undo" + ] +}