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,15 +53,18 @@ 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) &&
? 1 !isLoading
: 0, ? 1
: 0,
child: IconButton.secondary( child: IconButton.secondary(
icon: const Icon(SpotubeIcons.queueAdd), icon: const Icon(SpotubeIcons.queueAdd),
onPressed: onAddToQueuePressed, onPressed: onAddToQueuePressed,
@ -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 ||
? 1 isPlaying ||
: 0, isLoading
? 1
: 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,88 +60,116 @@ 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, );
), var isLoading =
margin: const EdgeInsets.symmetric(horizontal: 10), (isPlaylistPlaying && isFetchingActiveTrack) || updating.value;
isPlaying: isPlaylistPlaying, var description =
isLoading: "${album.albumType?.formatted}${album.artists?.asString() ?? ""}";
(isPlaylistPlaying && isFetchingActiveTrack) || updating.value,
title: album.name!, void onTap() {
description: ServiceUtils.pushNamed(
"${album.albumType?.formatted}${album.artists?.asString() ?? ""}", context,
onTap: () { AlbumPage.name,
ServiceUtils.pushNamed( pathParameters: {
context, "id": album.id!,
AlbumPage.name, },
pathParameters: { extra: album,
"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,
),
); );
}, } else {
onPlaybuttonPressed: () async { await playlistNotifier.load(fetchedTracks, autoPlay: true);
updating.value = true; playlistNotifier.addCollection(album.id!);
try { historyNotifier.addAlbums([album]);
if (isPlaylistPlaying) { }
return playing ? audioPlayer.pause() : audioPlayer.resume(); } 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 (fetchedTracks.isEmpty) return;
if (isRemoteDevice) { playlistNotifier.addTracks(fetchedTracks);
final remotePlayback = ref.read(connectProvider.notifier); playlistNotifier.addCollection(album.id!);
await remotePlayback.load( historyNotifier.addAlbums([album]);
WebSocketLoadEventData.album( if (context.mounted) {
tracks: fetchedTracks, showToast(
collection: album, 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;
} finally { }
updating.value = false; }
}
},
onAddToQueuePressed: () async {
if (isPlaylistPlaying) {
return;
}
updating.value = true; if (_isTile) {
try { return PlaybuttonTile(
final fetchedTracks = await fetchAllTrack(); imageUrl: imageUrl,
isPlaying: isPlaylistPlaying,
isLoading: isLoading,
title: album.name!,
description: description,
onTap: onTap,
onPlaybuttonPressed: onPlaybuttonPressed,
onAddToQueuePressed: onAddToQueuePressed,
);
}
if (fetchedTracks.isEmpty) return; return PlaybuttonCard(
playlistNotifier.addTracks(fetchedTracks); imageUrl: imageUrl,
playlistNotifier.addCollection(album.id!); isPlaying: isPlaylistPlaying,
historyNotifier.addAlbums([album]); isLoading: isLoading,
if (context.mounted) { title: album.name!,
final snackbar = SnackBar( description: description,
content: Text( onTap: onTap,
context.l10n.added_to_queue(fetchedTracks.length), onPlaybuttonPressed: onPlaybuttonPressed,
), onAddToQueuePressed: onAddToQueuePressed,
action: SnackBarAction( );
label: "Undo",
onPressed: () {
playlistNotifier
.removeTracks(fetchedTracks.map((e) => e.id!));
},
),
);
scaffoldMessenger?.showSnackBar(snackbar);
}
} finally {
updating.value = false;
}
});
} }
} }

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, controller: controller,
gridDelegate: itemCount: albums.length,
const SliverGridDelegateWithMaxCrossAxisExtent( hasMore: albumsQuery.asData?.value.hasMore == true,
maxCrossAxisExtent: 150, isLoading: albumsQuery.isLoading,
mainAxisExtent: 225, onRequestMore: albumsQueryNotifier.fetchMore,
crossAxisSpacing: 8, gridItemBuilder: (context, index) => AlbumCard(
mainAxisSpacing: 8, albums[index],
), ),
itemBuilder: (context, index) { listItemBuilder: (context, index) =>
if (albums.isNotEmpty && index == albums.length) { AlbumCard.tile(albums[index]),
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,
),
);
},
), ),
), ),
], ],

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, controller: controller,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( hasMore: playlistsQuery.asData?.value.hasMore == true,
maxCrossAxisExtent: 150, isLoading: playlistsQuery.isLoading,
mainAxisExtent: 225, onRequestMore: playlistsQueryNotifier.fetchMore,
crossAxisSpacing: 8, itemCount: playlists.length,
mainAxisSpacing: 8, gridItemBuilder: (context, index) {
), return PlaylistCard(playlists[index]);
itemBuilder: (context, index) { },
if (playlists.isNotEmpty && index == playlists.length) { listItemBuilder: (context, index) {
if (playlistsQuery.asData?.value.hasMore != true) { return PlaylistCard.tile(playlists[index]);
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,
);
}, },
), ),
), ),

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,10 +133,12 @@ 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(
shuffled child: Text(
? context.l10n.unshuffle_playlist shuffled
: context.l10n.shuffle_playlist, ? context.l10n.unshuffle_playlist
: context.l10n.shuffle_playlist,
),
), ),
child: IconButton( child: IconButton(
icon: const Icon(SpotubeIcons.shuffle), icon: const Icon(SpotubeIcons.shuffle),
@ -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,10 +167,12 @@ class PlayerControls extends HookConsumerWidget {
), ),
), ),
Tooltip( Tooltip(
tooltip: Text( tooltip: TooltipContainer(
playing child: Text(
? context.l10n.pause_playback playing
: context.l10n.resume_playback, ? context.l10n.pause_playback
: context.l10n.resume_playback,
),
), ),
child: IconButton.primary( child: IconButton.primary(
shape: ButtonShape.circle, shape: ButtonShape.circle,
@ -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,12 +207,14 @@ class PlayerControls extends HookConsumerWidget {
.watch(audioPlayerProvider.select((s) => s.loopMode)); .watch(audioPlayerProvider.select((s) => s.loopMode));
return Tooltip( return Tooltip(
tooltip: Text( tooltip: TooltipContainer(
loopMode == PlaylistMode.single child: Text(
? context.l10n.loop_track loopMode == PlaylistMode.single
: loopMode == PlaylistMode.loop ? context.l10n.loop_track
? context.l10n.repeat_playlist : loopMode == PlaylistMode.loop
: "", ? context.l10n.repeat_playlist
: "",
),
), ),
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(

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,96 +69,128 @@ 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), ServiceUtils.pushNamed(
title: playlist.name!, context,
description: playlist.description, PlaylistPage.name,
imageUrl: playlist.images.asUrlString( pathParameters: {
placeholder: ImagePlaceholder.collection, "id": playlist.id!,
), },
isPlaying: isPlaylistPlaying, extra: playlist,
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();
}
final fetchedInitialTracks = await fetchInitialTracks(); void onPlaybuttonPressed() async {
try {
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 {
updating.value = true; updating.value = true;
try { if (isPlaylistPlaying && playing) {
if (isPlaylistPlaying) return; 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!); playlistNotifier.addCollection(playlist.id!);
historyNotifier.addPlaylists([playlist]); historyNotifier.addPlaylists([playlist]);
if (context.mounted) {
final snackbar = SnackBar( final allTracks = await fetchAllTracks();
content: Text(context.l10n
.added_num_tracks_to_queue(fetchedInitialTracks.length)), await playlistNotifier
action: SnackBarAction( .addTracks(allTracks.sublist(fetchedInitialTracks.length));
label: "Undo", }
onPressed: () { } finally {
playlistNotifier if (context.mounted) {
.removeTracks(fetchedInitialTracks.map((e) => e.id!));
},
),
);
ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar);
}
} finally {
updating.value = false; 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,
); );
} }
} }

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,47 +20,72 @@ 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: [
title: Text(section.title ?? ""), TitleBar(
automaticallyImplyLeading: true, title: Text(section.title ?? ""),
), automaticallyImplyLeading: true,
body: CustomScrollView( )
slivers: [ ],
SliverLayoutBuilder( child: Padding(
builder: (context, constrains) { padding: const EdgeInsets.symmetric(horizontal: 8.0),
return SliverGrid.builder( child: CustomScrollView(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( controller: controller,
slivers: [
if (isArtist)
SliverGrid.builder(
gridDelegate: const 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(
), child: SafeArea(
const SliverToBoxAdapter( child: SizedBox(),
child: SafeArea( ),
child: SizedBox(),
), ),
), ],
], ),
), ),
), ),
); );

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,123 +40,93 @@ class GenrePlaylistsPage extends HookConsumerWidget {
); );
return Scaffold( return Scaffold(
appBar: kIsDesktop headers: [
? const TitleBar( if (kIsDesktop)
leading: [BackButton(color: Colors.white)], const TitleBar(
backgroundColor: Colors.transparent, leading: [
foregroundColor: Colors.white, BackButton(),
) ],
: null, backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true, surfaceOpacity: 0,
body: DecoratedBox( surfaceBlur: 0,
)
],
floatingHeader: true,
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: CustomScrollView( child: SurfaceCard(
controller: scrollController, borderRadius: BorderRadius.zero,
slivers: [ padding: EdgeInsets.zero,
SliverAppBar( child: CustomScrollView(
automaticallyImplyLeading: kIsMobile, controller: scrollController,
expandedHeight: mediaQuery.mdAndDown ? 200 : 150, slivers: [
title: const Text(""), SliverAppBar(
backgroundColor: Colors.transparent, automaticallyImplyLeading: false,
flexibleSpace: FlexibleSpaceBar( leading: kIsMobile ? const BackButton() : null,
centerTitle: kIsDesktop, expandedHeight: mediaQuery.mdAndDown ? 200 : 150,
title: Text( title: const Text(""),
category.name!, backgroundColor: Colors.transparent,
style: Theme.of(context).textTheme.headlineMedium?.copyWith( flexibleSpace: FlexibleSpaceBar(
color: Colors.white, centerTitle: kIsDesktop,
letterSpacing: 3, title: Text(
shadows: [ category.name!,
const Shadow( style: context.theme.typography.h3.copyWith(
offset: Offset(-1.5, -1.5), color: Colors.white,
color: Colors.black54, letterSpacing: 3,
), 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),
), ),
], 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),
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),
],
), ),
), ),
); );

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"
]
}