feat: grid/list customizable playbutton view

This commit is contained in:
Kingkor Roy Tirtho 2024-12-22 14:48:48 +06:00
parent 05d544fe5a
commit a6720d5392
19 changed files with 849 additions and 429 deletions

View File

@ -130,4 +130,6 @@ abstract class SpotubeIcons {
static const open = FeatherIcons.externalLink; static const open = FeatherIcons.externalLink;
static const radioChecked = Icons.radio_button_on_rounded; static const radioChecked = Icons.radio_button_on_rounded;
static const radioUnchecked = Icons.radio_button_off_rounded; static const radioUnchecked = Icons.radio_button_off_rounded;
static const grid = FeatherIcons.grid;
static const list = FeatherIcons.list;
} }

View File

@ -139,7 +139,9 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
if (mediaQuery.mdAndUp) { if (mediaQuery.mdAndUp) {
return Tooltip( return Tooltip(
tooltip: Text(tooltip ?? ''), tooltip: TooltipContainer(
child: Text(tooltip ?? ''),
),
child: IconButton.ghost( child: IconButton.ghost(
icon: icon ?? const Icon(SpotubeIcons.moreVertical), icon: icon ?? const Icon(SpotubeIcons.moreVertical),
onPressed: () { onPressed: () {
@ -162,7 +164,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
if (child != null) { if (child != null) {
return Tooltip( return Tooltip(
tooltip: 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: const ButtonStyle.ghost(),
@ -172,7 +174,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
} }
return Tooltip( return Tooltip(
tooltip: Text(tooltip ?? ''), tooltip: TooltipContainer(child: Text(tooltip ?? '')),
child: IconButton.ghost( child: IconButton.ghost(
icon: icon ?? const Icon(SpotubeIcons.moreVertical), icon: icon ?? const Icon(SpotubeIcons.moreVertical),
onPressed: () => showDropdownMenu(context, Offset.zero), onPressed: () => showDropdownMenu(context, Offset.zero),

View File

@ -2,13 +2,19 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
class BackButton extends StatelessWidget { class BackButton extends StatelessWidget {
const BackButton({super.key}); final Color? color;
const BackButton({
super.key,
this.color,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton.ghost( return IconButton.ghost(
size: const ButtonSize(.9), 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(), onPressed: () => Navigator.of(context).pop(),
); );
} }

View File

@ -9,7 +9,6 @@ import 'package:spotube/collections/fake.dart';
import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/modules/artist/artist_card.dart';
import 'package:spotube/modules/playlist/playlist_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'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class HorizontalPlaybuttonCardView<T> extends HookWidget { class HorizontalPlaybuttonCardView<T> extends HookWidget {
@ -38,12 +37,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scrollController = useScrollController(); final scrollController = useScrollController();
final height = useBreakpointValue<double>( final isArtist = items.every((s) => s is Artist);
xs: 226,
sm: 226,
md: 236,
others: 266,
);
return Padding( return Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
@ -64,7 +58,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
], ],
), ),
SizedBox( SizedBox(
height: height, height: isArtist ? 250 : 225,
child: NotificationListener( child: NotificationListener(
// disable multiple scrollbar to use this // disable multiple scrollbar to use this
onNotification: (notification) => true, onNotification: (notification) => true,
@ -88,7 +82,9 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
onFetchData: onFetchMore, onFetchData: onFetchMore,
loadingBuilder: (context) => Skeletonizer( loadingBuilder: (context) => Skeletonizer(
enabled: true, enabled: true,
child: AlbumCard(FakeData.albumSimple), child: isArtist
? ArtistCard(FakeData.artist)
: AlbumCard(FakeData.albumSimple),
), ),
isLoading: isLoadingNextPage, isLoading: isLoadingNextPage,
hasReachedMax: !hasNextPage, hasReachedMax: !hasNextPage,
@ -100,11 +96,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
PlaylistSimple() => PlaylistSimple() =>
PlaylistCard(item as PlaylistSimple), PlaylistCard(item as PlaylistSimple),
AlbumSimple() => AlbumCard(item as AlbumSimple), AlbumSimple() => AlbumCard(item as AlbumSimple),
Artist() => Padding( Artist() => ArtistCard(item as Artist),
padding: const EdgeInsets.symmetric(
horizontal: 12.0),
child: ArtistCard(item as Artist),
),
_ => const SizedBox.shrink(), _ => const SizedBox.shrink(),
}; };
}), }),

View File

@ -1,17 +1,15 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/string.dart'; import 'package:spotube/extensions/string.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
class PlaybuttonCard extends HookWidget { class PlaybuttonCard extends StatelessWidget {
final void Function()? onTap; final void Function()? onTap;
final void Function()? onPlaybuttonPressed; final void Function()? onPlaybuttonPressed;
final void Function()? onAddToQueuePressed; final void Function()? onAddToQueuePressed;
final String? description; final String? description;
final EdgeInsetsGeometry? margin;
final String imageUrl; final String imageUrl;
final bool isPlaying; final bool isPlaying;
final bool isLoading; final bool isLoading;
@ -23,7 +21,6 @@ class PlaybuttonCard extends HookWidget {
required this.isPlaying, required this.isPlaying,
required this.isLoading, required this.isLoading,
required this.title, required this.title,
this.margin,
this.description, this.description,
this.onPlaybuttonPressed, this.onPlaybuttonPressed,
this.onAddToQueuePressed, this.onAddToQueuePressed,
@ -56,13 +53,16 @@ class PlaybuttonCard extends HookWidget {
AnimatedScale( AnimatedScale(
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
scale: states.contains(WidgetState.hovered) || kIsMobile scale: (states.contains(WidgetState.hovered) ||
kIsMobile) &&
!isLoading
? 1 ? 1
: 0.7, : 0.7,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
opacity: opacity: (states.contains(WidgetState.hovered) ||
states.contains(WidgetState.hovered) || kIsMobile kIsMobile) &&
!isLoading
? 1 ? 1
: 0, : 0,
child: IconButton.secondary( child: IconButton.secondary(
@ -76,17 +76,29 @@ class PlaybuttonCard extends HookWidget {
AnimatedScale( AnimatedScale(
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
scale: states.contains(WidgetState.hovered) || kIsMobile scale: states.contains(WidgetState.hovered) ||
kIsMobile ||
isPlaying ||
isLoading
? 1 ? 1
: 0.7, : 0.7,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
opacity: opacity: states.contains(WidgetState.hovered) ||
states.contains(WidgetState.hovered) || kIsMobile kIsMobile ||
isPlaying ||
isLoading
? 1 ? 1
: 0, : 0,
child: IconButton.secondary( 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, onPressed: onPlaybuttonPressed,
size: ButtonSize.small, 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( title: Tooltip(
tooltip: Text(title), tooltip: TooltipContainer(child: Text(title)),
child: Text( child: Text(
title, title,
maxLines: 1, maxLines: 1,

View File

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

View File

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

View File

@ -401,5 +401,6 @@
"export_cache_files": "Export Cached Files", "export_cache_files": "Export Cached Files",
"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"
} }

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.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/dialogs/select_device_dialog.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/artist_simple.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';
@ -24,10 +25,16 @@ extension FormattedAlbumType on AlbumType {
class AlbumCard extends HookConsumerWidget { class AlbumCard extends HookConsumerWidget {
final AlbumSimple album; final AlbumSimple album;
final bool _isTile;
const AlbumCard( const AlbumCard(
this.album, { this.album, {
super.key, super.key,
}); }) : _isTile = false;
const AlbumCard.tile(
this.album, {
super.key,
}) : _isTile = true;
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -45,8 +52,6 @@ class AlbumCard extends HookConsumerWidget {
final updating = useState(false); final updating = useState(false);
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
Future<List<Track>> fetchAllTrack() async { Future<List<Track>> fetchAllTrack() async {
if (album.tracks != null && album.tracks!.isNotEmpty) { if (album.tracks != null && album.tracks!.isNotEmpty) {
return album.tracks!.map((track) => track.asTrack(album)).toList(); return album.tracks!.map((track) => track.asTrack(album)).toList();
@ -55,18 +60,15 @@ class AlbumCard extends HookConsumerWidget {
return ref.read(albumTracksProvider(album).notifier).fetchAll(); return ref.read(albumTracksProvider(album).notifier).fetchAll();
} }
return PlaybuttonCard( var imageUrl = album.images.asUrlString(
imageUrl: album.images.asUrlString(
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
), );
margin: const EdgeInsets.symmetric(horizontal: 10), var isLoading =
isPlaying: isPlaylistPlaying, (isPlaylistPlaying && isFetchingActiveTrack) || updating.value;
isLoading: var description =
(isPlaylistPlaying && isFetchingActiveTrack) || updating.value, "${album.albumType?.formatted}${album.artists?.asString() ?? ""}";
title: album.name!,
description: void onTap() {
"${album.albumType?.formatted}${album.artists?.asString() ?? ""}",
onTap: () {
ServiceUtils.pushNamed( ServiceUtils.pushNamed(
context, context,
AlbumPage.name, AlbumPage.name,
@ -75,8 +77,9 @@ class AlbumCard extends HookConsumerWidget {
}, },
extra: album, extra: album,
); );
}, }
onPlaybuttonPressed: () async {
void onPlaybuttonPressed() async {
updating.value = true; updating.value = true;
try { try {
if (isPlaylistPlaying) { if (isPlaylistPlaying) {
@ -104,8 +107,9 @@ class AlbumCard extends HookConsumerWidget {
} finally { } finally {
updating.value = false; updating.value = false;
} }
}, }
onAddToQueuePressed: () async {
void onAddToQueuePressed() async {
if (isPlaylistPlaying) { if (isPlaylistPlaying) {
return; return;
} }
@ -119,24 +123,53 @@ class AlbumCard extends HookConsumerWidget {
playlistNotifier.addCollection(album.id!); playlistNotifier.addCollection(album.id!);
historyNotifier.addAlbums([album]); historyNotifier.addAlbums([album]);
if (context.mounted) { if (context.mounted) {
final snackbar = SnackBar( showToast(
context: context,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
content: Text( content: Text(
context.l10n.added_to_queue(fetchedTracks.length), context.l10n.added_to_queue(fetchedTracks.length),
), ),
action: SnackBarAction( trailing: Button.outline(
label: "Undo", child: Text(context.l10n.undo),
onPressed: () { onPressed: () {
playlistNotifier playlistNotifier
.removeTracks(fetchedTracks.map((e) => e.id!)); .removeTracks(fetchedTracks.map((e) => e.id!));
}, },
), ),
),
);
},
); );
scaffoldMessenger?.showSnackBar(snackbar);
} }
} finally { } finally {
updating.value = false; updating.value = false;
} }
}); }
if (_isTile) {
return PlaybuttonTile(
imageUrl: imageUrl,
isPlaying: isPlaylistPlaying,
isLoading: isLoading,
title: album.name!,
description: description,
onTap: onTap,
onPlaybuttonPressed: onPlaybuttonPressed,
onAddToQueuePressed: onAddToQueuePressed,
);
}
return PlaybuttonCard(
imageUrl: imageUrl,
isPlaying: isPlaylistPlaying,
isLoading: isLoading,
title: album.name!,
description: description,
onTap: onTap,
onPlaybuttonPressed: onPlaybuttonPressed,
onAddToQueuePressed: onAddToQueuePressed,
);
} }
} }

View File

@ -4,14 +4,12 @@ import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/waypoint.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
@ -78,39 +76,17 @@ class UserAlbums extends HookConsumerWidget {
const SliverGap(10), const SliverGap(10),
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: SliverGrid.builder( sliver: PlaybuttonView(
itemCount: albums.isEmpty ? 6 : albums.length + 1,
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
mainAxisExtent: 225,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemBuilder: (context, index) {
if (albums.isNotEmpty && index == albums.length) {
if (albumsQuery.asData?.value.hasMore != true) {
return const SizedBox.shrink();
}
return Waypoint(
controller: controller, controller: controller,
isGrid: true, itemCount: albums.length,
onTouchEdge: albumsQueryNotifier.fetchMore, hasMore: albumsQuery.asData?.value.hasMore == true,
child: Skeletonizer( isLoading: albumsQuery.isLoading,
enabled: true, onRequestMore: albumsQueryNotifier.fetchMore,
child: AlbumCard(FakeData.albumSimple), gridItemBuilder: (context, index) => AlbumCard(
albums[index],
), ),
); listItemBuilder: (context, index) =>
} AlbumCard.tile(albums[index]),
return Skeletonizer(
enabled: albumsQuery.isLoading,
child: AlbumCard(
albums.elementAtOrNull(index) ?? FakeData.albumSimple,
),
);
},
), ),
), ),
], ],

View File

@ -5,16 +5,14 @@ import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Image; import 'package:shadcn_flutter/shadcn_flutter.dart' hide Image;
import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart';
import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/components/waypoint.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/authentication/authentication.dart';
@ -127,35 +125,17 @@ class UserPlaylists extends HookConsumerWidget {
const SliverGap(10), const SliverGap(10),
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: SliverGrid.builder( sliver: PlaybuttonView(
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, controller: controller,
isGrid: true, hasMore: playlistsQuery.asData?.value.hasMore == true,
onTouchEdge: playlistsQueryNotifier.fetchMore, isLoading: playlistsQuery.isLoading,
child: Skeletonizer( onRequestMore: playlistsQueryNotifier.fetchMore,
enabled: true, itemCount: playlists.length,
child: PlaylistCard(FakeData.playlistSimple), gridItemBuilder: (context, index) {
), return PlaylistCard(playlists[index]);
); },
} listItemBuilder: (context, index) {
return PlaylistCard.tile(playlists[index]);
return PlaylistCard(
playlists.elementAtOrNull(index) ??
FakeData.playlistSimple,
);
}, },
), ),
), ),

View File

@ -79,7 +79,7 @@ class PlayerActions extends HookConsumerWidget {
children: [ children: [
if (showQueue) if (showQueue)
Tooltip( Tooltip(
tooltip: Text(context.l10n.queue), tooltip: TooltipContainer(child: Text(context.l10n.queue)),
child: IconButton.ghost( child: IconButton.ghost(
icon: const Icon(SpotubeIcons.queue), icon: const Icon(SpotubeIcons.queue),
enabled: playlist.activeTrack != null, enabled: playlist.activeTrack != null,
@ -115,7 +115,8 @@ class PlayerActions extends HookConsumerWidget {
), ),
if (!isLocalTrack) if (!isLocalTrack)
Tooltip( Tooltip(
tooltip: Text(context.l10n.alternative_track_sources), tooltip: TooltipContainer(
child: Text(context.l10n.alternative_track_sources)),
child: IconButton.ghost( child: IconButton.ghost(
icon: const Icon(SpotubeIcons.alternativeRoute), icon: const Icon(SpotubeIcons.alternativeRoute),
onPressed: playlist.activeTrack != null onPressed: playlist.activeTrack != null
@ -147,7 +148,8 @@ class PlayerActions extends HookConsumerWidget {
) )
else else
Tooltip( Tooltip(
tooltip: Text(context.l10n.download_track), tooltip:
TooltipContainer(child: Text(context.l10n.download_track)),
child: IconButton.ghost( child: IconButton.ghost(
icon: Icon( icon: Icon(
isDownloaded ? SpotubeIcons.done : SpotubeIcons.download, isDownloaded ? SpotubeIcons.done : SpotubeIcons.download,

View File

@ -84,7 +84,8 @@ class PlayerControls extends HookConsumerWidget {
return Column( return Column(
children: [ children: [
Tooltip( Tooltip(
tooltip: Text(context.l10n.slide_to_seek), tooltip: TooltipContainer(
child: Text(context.l10n.slide_to_seek)),
child: Slider( child: Slider(
value: value:
SliderValue.single(progress.value.toDouble()), SliderValue.single(progress.value.toDouble()),
@ -132,11 +133,13 @@ class PlayerControls extends HookConsumerWidget {
final shuffled = ref final shuffled = ref
.watch(audioPlayerProvider.select((s) => s.shuffled)); .watch(audioPlayerProvider.select((s) => s.shuffled));
return Tooltip( return Tooltip(
tooltip: Text( tooltip: TooltipContainer(
child: Text(
shuffled shuffled
? context.l10n.unshuffle_playlist ? context.l10n.unshuffle_playlist
: context.l10n.shuffle_playlist, : context.l10n.shuffle_playlist,
), ),
),
child: IconButton( child: IconButton(
icon: const Icon(SpotubeIcons.shuffle), icon: const Icon(SpotubeIcons.shuffle),
variance: shuffled variance: shuffled
@ -155,7 +158,8 @@ class PlayerControls extends HookConsumerWidget {
); );
}), }),
Tooltip( Tooltip(
tooltip: Text(context.l10n.previous_track), tooltip: TooltipContainer(
child: Text(context.l10n.previous_track)),
child: IconButton.ghost( child: IconButton.ghost(
enabled: !isFetchingActiveTrack, enabled: !isFetchingActiveTrack,
icon: const Icon(SpotubeIcons.skipBack), icon: const Icon(SpotubeIcons.skipBack),
@ -163,11 +167,13 @@ class PlayerControls extends HookConsumerWidget {
), ),
), ),
Tooltip( Tooltip(
tooltip: Text( tooltip: TooltipContainer(
child: Text(
playing playing
? context.l10n.pause_playback ? context.l10n.pause_playback
: context.l10n.resume_playback, : context.l10n.resume_playback,
), ),
),
child: IconButton.primary( child: IconButton.primary(
shape: ButtonShape.circle, shape: ButtonShape.circle,
icon: isFetchingActiveTrack icon: isFetchingActiveTrack
@ -188,7 +194,8 @@ class PlayerControls extends HookConsumerWidget {
), ),
), ),
Tooltip( Tooltip(
tooltip: Text(context.l10n.next_track), tooltip:
TooltipContainer(child: Text(context.l10n.next_track)),
child: IconButton.ghost( child: IconButton.ghost(
icon: const Icon(SpotubeIcons.skipForward), icon: const Icon(SpotubeIcons.skipForward),
onPressed: onPressed:
@ -200,13 +207,15 @@ class PlayerControls extends HookConsumerWidget {
.watch(audioPlayerProvider.select((s) => s.loopMode)); .watch(audioPlayerProvider.select((s) => s.loopMode));
return Tooltip( return Tooltip(
tooltip: Text( tooltip: TooltipContainer(
child: Text(
loopMode == PlaylistMode.single loopMode == PlaylistMode.single
? context.l10n.loop_track ? context.l10n.loop_track
: loopMode == PlaylistMode.loop : loopMode == PlaylistMode.loop
? context.l10n.repeat_playlist ? context.l10n.repeat_playlist
: "", : "",
), ),
),
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(
loopMode == PlaylistMode.single loopMode == PlaylistMode.single

View File

@ -160,7 +160,8 @@ class PlayerQueue extends HookConsumerWidget {
if (mediaQuery.mdAndUp || !isSearching.value) ...[ if (mediaQuery.mdAndUp || !isSearching.value) ...[
const SizedBox(width: 10), const SizedBox(width: 10),
Tooltip( Tooltip(
tooltip: Text(context.l10n.clear_all), tooltip: TooltipContainer(
child: Text(context.l10n.clear_all)),
child: IconButton.outline( child: IconButton.outline(
icon: const Icon(SpotubeIcons.playlistRemove), icon: const Icon(SpotubeIcons.playlistRemove),
onPressed: () { onPressed: () {

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.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/dialogs/select_device_dialog.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/context.dart';
import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
@ -18,10 +19,18 @@ import 'package:spotube/utils/service_utils.dart';
class PlaylistCard extends HookConsumerWidget { class PlaylistCard extends HookConsumerWidget {
final PlaylistSimple playlist; final PlaylistSimple playlist;
final bool _isTile;
const PlaylistCard( const PlaylistCard(
this.playlist, { this.playlist, {
super.key, super.key,
}); }) : _isTile = false;
const PlaylistCard.tile(
this.playlist, {
super.key,
}) : _isTile = true;
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlistQueue = ref.watch(audioPlayerProvider); final playlistQueue = ref.watch(audioPlayerProvider);
@ -60,18 +69,7 @@ class PlaylistCard extends HookConsumerWidget {
return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll();
} }
return PlaybuttonCard( void onTap() {
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( ServiceUtils.pushNamed(
context, context,
PlaylistPage.name, PlaylistPage.name,
@ -80,8 +78,9 @@ class PlaylistCard extends HookConsumerWidget {
}, },
extra: playlist, extra: playlist,
); );
}, }
onPlaybuttonPressed: () async {
void onPlaybuttonPressed() async {
try { try {
updating.value = true; updating.value = true;
if (isPlaylistPlaying && playing) { if (isPlaylistPlaying && playing) {
@ -119,8 +118,9 @@ class PlaylistCard extends HookConsumerWidget {
updating.value = false; updating.value = false;
} }
} }
}, }
onAddToQueuePressed: () async {
void onAddToQueuePressed() async {
updating.value = true; updating.value = true;
try { try {
if (isPlaylistPlaying) return; if (isPlaylistPlaying) return;
@ -133,23 +133,64 @@ class PlaylistCard extends HookConsumerWidget {
playlistNotifier.addCollection(playlist.id!); playlistNotifier.addCollection(playlist.id!);
historyNotifier.addPlaylists([playlist]); historyNotifier.addPlaylists([playlist]);
if (context.mounted) { if (context.mounted) {
final snackbar = SnackBar( showToast(
content: Text(context.l10n context: context,
.added_num_tracks_to_queue(fetchedInitialTracks.length)), builder: (context, overlay) {
action: SnackBarAction( return SurfaceCard(
label: "Undo", child: Basic(
content: Text(
context.l10n
.added_num_tracks_to_queue(fetchedInitialTracks.length),
),
trailing: Button.outline(
child: Text(context.l10n.undo),
onPressed: () { onPressed: () {
playlistNotifier playlistNotifier
.removeTracks(fetchedInitialTracks.map((e) => e.id!)); .removeTracks(fetchedInitialTracks.map((e) => e.id!));
}, },
), ),
),
);
},
); );
ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar);
} }
} finally { } finally {
updating.value = false; 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,
); );
} }
} }

View File

@ -74,7 +74,8 @@ class BottomPlayer extends HookConsumerWidget {
PlayerActions( PlayerActions(
extraActions: [ extraActions: [
Tooltip( Tooltip(
tooltip: Text(context.l10n.mini_player), tooltip:
TooltipContainer(child: Text(context.l10n.mini_player)),
child: IconButton( child: IconButton(
variance: ButtonVariance.ghost, variance: ButtonVariance.ghost,
icon: const Icon(SpotubeIcons.miniPlayer), icon: const Icon(SpotubeIcons.miniPlayer),

View File

@ -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:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/modules/artist/artist_card.dart';
import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/provider/spotify/views/home_section.dart'; import 'package:spotube/provider/spotify/views/home_section.dart';
class HomeFeedSectionPage extends HookConsumerWidget { class HomeFeedSectionPage extends HookConsumerWidget {
@ -19,39 +20,63 @@ class HomeFeedSectionPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final homeFeedSection = ref.watch(homeSectionViewProvider(sectionUri)); final homeFeedSection = ref.watch(homeSectionViewProvider(sectionUri));
final section = homeFeedSection.asData?.value ?? FakeData.feedSection; final section = homeFeedSection.asData?.value ?? FakeData.feedSection;
final controller = useScrollController();
final isArtist = section.items.every((item) => item.artist != null);
return Skeletonizer( return Skeletonizer(
enabled: homeFeedSection.isLoading, enabled: homeFeedSection.isLoading,
child: Scaffold( child: Scaffold(
appBar: TitleBar( headers: [
TitleBar(
title: Text(section.title ?? ""), title: Text(section.title ?? ""),
automaticallyImplyLeading: true, automaticallyImplyLeading: true,
), )
body: CustomScrollView( ],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: CustomScrollView(
controller: controller,
slivers: [ slivers: [
SliverLayoutBuilder( if (isArtist)
builder: (context, constrains) { SliverGrid.builder(
return SliverGrid.builder( gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200, maxCrossAxisExtent: 200,
mainAxisExtent: constrains.smAndDown ? 225 : 250, mainAxisExtent: 250,
crossAxisSpacing: 8, crossAxisSpacing: 8,
mainAxisSpacing: 8, mainAxisSpacing: 8,
), ),
itemCount: section.items.length, itemCount: section.items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = section.items[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) { if (item.album != null) {
return AlbumCard(item.album!.asAlbum); return AlbumCard(item.album!.asAlbum);
} else if (item.artist != null) { }
return ArtistCard(item.artist!.asArtist); if (item.playlist != null) {
} else if (item.playlist != null) {
return PlaylistCard(item.playlist!.asPlaylist); return PlaylistCard(item.playlist!.asPlaylist);
} }
return const SizedBox(); return const SizedBox.shrink();
},
);
}, },
), ),
const SliverToBoxAdapter( const SliverToBoxAdapter(
@ -62,6 +87,7 @@ class HomeFeedSectionPage extends HookConsumerWidget {
], ],
), ),
), ),
),
); );
} }
} }

View File

@ -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:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package: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: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/hooks/utils/use_custom_status_bar_color.dart';
import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/waypoint.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify/spotify.dart';
import 'package:collection/collection.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
class GenrePlaylistsPage extends HookConsumerWidget { class GenrePlaylistsPage extends HookConsumerWidget {
@ -39,33 +40,37 @@ class GenrePlaylistsPage extends HookConsumerWidget {
); );
return Scaffold( return Scaffold(
appBar: kIsDesktop headers: [
? const TitleBar( if (kIsDesktop)
leading: [BackButton(color: Colors.white)], const TitleBar(
leading: [
BackButton(),
],
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Colors.white, surfaceOpacity: 0,
surfaceBlur: 0,
) )
: null, ],
extendBodyBehindAppBar: true, floatingHeader: true,
body: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
image: DecorationImage( image: DecorationImage(
image: UniversalImage.imageProvider(category.icons!.first.url!), image: UniversalImage.imageProvider(category.icons!.first.url!),
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
fit: BoxFit.cover, fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(0.5),
BlendMode.darken,
),
repeat: ImageRepeat.noRepeat, repeat: ImageRepeat.noRepeat,
matchTextDirection: true, matchTextDirection: true,
), ),
), ),
child: SurfaceCard(
borderRadius: BorderRadius.zero,
padding: EdgeInsets.zero,
child: CustomScrollView( child: CustomScrollView(
controller: scrollController, controller: scrollController,
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
automaticallyImplyLeading: kIsMobile, automaticallyImplyLeading: false,
leading: kIsMobile ? const BackButton() : null,
expandedHeight: mediaQuery.mdAndDown ? 200 : 150, expandedHeight: mediaQuery.mdAndDown ? 200 : 150,
title: const Text(""), title: const Text(""),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@ -73,25 +78,25 @@ class GenrePlaylistsPage extends HookConsumerWidget {
centerTitle: kIsDesktop, centerTitle: kIsDesktop,
title: Text( title: Text(
category.name!, category.name!,
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: context.theme.typography.h3.copyWith(
color: Colors.white, color: Colors.white,
letterSpacing: 3, letterSpacing: 3,
shadows: [ shadows: [
const Shadow( Shadow(
offset: Offset(-1.5, -1.5), offset: const Offset(-1.5, -1.5),
color: Colors.black54, color: Colors.black.withAlpha(138),
), ),
const Shadow( Shadow(
offset: Offset(1.5, -1.5), offset: const Offset(1.5, -1.5),
color: Colors.black54, color: Colors.black.withAlpha(138),
), ),
const Shadow( Shadow(
offset: Offset(1.5, 1.5), offset: const Offset(1.5, 1.5),
color: Colors.black54, color: Colors.black.withAlpha(138),
), ),
const Shadow( Shadow(
offset: Offset(-1.5, 1.5), offset: const Offset(-1.5, 1.5),
color: Colors.black54, color: Colors.black.withAlpha(138),
), ),
], ],
), ),
@ -106,51 +111,16 @@ class GenrePlaylistsPage extends HookConsumerWidget {
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: mediaQuery.mdAndDown ? 12 : 24, horizontal: mediaQuery.mdAndDown ? 12 : 24,
), ),
sliver: playlists.asData?.value.items.isNotEmpty != true sliver: PlaybuttonView(
? 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, controller: scrollController,
isGrid: true, itemCount: playlists.asData?.value.items.length ?? 0,
onTouchEdge: playlistsNotifier.fetchMore, isLoading: playlists.isLoading,
child: PlaylistCard(FakeData.playlist), hasMore: playlists.asData?.value.hasMore == true,
), onRequestMore: playlistsNotifier.fetchMore,
); listItemBuilder: (context, index) =>
} PlaylistCard.tile(playlists.asData!.value.items[index]),
gridItemBuilder: (context, index) =>
return Skeleton.keep( PlaylistCard(playlists.asData!.value.items[index]),
child: PlaylistCard(playlist),
);
},
), ),
), ),
), ),
@ -158,6 +128,7 @@ class GenrePlaylistsPage extends HookConsumerWidget {
], ],
), ),
), ),
),
); );
} }
} }

View File

@ -1 +1,105 @@
{} {
"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"
]
}