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

View File

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

View File

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

View File

@ -1,17 +1,15 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/utils/platform.dart';
class PlaybuttonCard extends HookWidget {
class PlaybuttonCard extends StatelessWidget {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
final void Function()? onAddToQueuePressed;
final String? description;
final EdgeInsetsGeometry? margin;
final String imageUrl;
final bool isPlaying;
final bool isLoading;
@ -23,7 +21,6 @@ class PlaybuttonCard extends HookWidget {
required this.isPlaying,
required this.isLoading,
required this.title,
this.margin,
this.description,
this.onPlaybuttonPressed,
this.onAddToQueuePressed,
@ -56,13 +53,16 @@ class PlaybuttonCard extends HookWidget {
AnimatedScale(
curve: Curves.easeOutBack,
duration: const Duration(milliseconds: 300),
scale: states.contains(WidgetState.hovered) || kIsMobile
scale: (states.contains(WidgetState.hovered) ||
kIsMobile) &&
!isLoading
? 1
: 0.7,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity:
states.contains(WidgetState.hovered) || kIsMobile
opacity: (states.contains(WidgetState.hovered) ||
kIsMobile) &&
!isLoading
? 1
: 0,
child: IconButton.secondary(
@ -76,17 +76,29 @@ class PlaybuttonCard extends HookWidget {
AnimatedScale(
curve: Curves.easeOutBack,
duration: const Duration(milliseconds: 150),
scale: states.contains(WidgetState.hovered) || kIsMobile
scale: states.contains(WidgetState.hovered) ||
kIsMobile ||
isPlaying ||
isLoading
? 1
: 0.7,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity:
states.contains(WidgetState.hovered) || kIsMobile
opacity: states.contains(WidgetState.hovered) ||
kIsMobile ||
isPlaying ||
isLoading
? 1
: 0,
child: IconButton.secondary(
icon: const Icon(SpotubeIcons.play),
icon: switch ((isLoading, isPlaying)) {
(true, _) => const CircularProgressIndicator(
size: 15,
),
(false, false) => const Icon(SpotubeIcons.play),
(false, true) => const Icon(SpotubeIcons.pause)
},
enabled: !isLoading,
onPressed: onPlaybuttonPressed,
size: ButtonSize.small,
),
@ -96,11 +108,23 @@ class PlaybuttonCard extends HookWidget {
),
);
},
)
),
if (isOwner)
const Positioned(
right: 5,
top: 5,
child: SecondaryBadge(
style: ButtonStyle.secondaryIcon(
shape: ButtonShape.circle,
size: ButtonSize.small,
),
child: Icon(SpotubeIcons.user),
),
),
],
),
title: Tooltip(
tooltip: Text(title),
tooltip: TooltipContainer(child: Text(title)),
child: Text(
title,
maxLines: 1,

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

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/playbutton_card.dart';
import 'package:spotube/components/playbutton_view/playbutton_card.dart';
import 'package:spotube/components/playbutton_view/playbutton_tile.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
@ -24,10 +25,16 @@ extension FormattedAlbumType on AlbumType {
class AlbumCard extends HookConsumerWidget {
final AlbumSimple album;
final bool _isTile;
const AlbumCard(
this.album, {
super.key,
});
}) : _isTile = false;
const AlbumCard.tile(
this.album, {
super.key,
}) : _isTile = true;
@override
Widget build(BuildContext context, ref) {
@ -45,8 +52,6 @@ class AlbumCard extends HookConsumerWidget {
final updating = useState(false);
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
Future<List<Track>> fetchAllTrack() async {
if (album.tracks != null && album.tracks!.isNotEmpty) {
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 PlaybuttonCard(
imageUrl: album.images.asUrlString(
var imageUrl = album.images.asUrlString(
placeholder: ImagePlaceholder.collection,
),
margin: const EdgeInsets.symmetric(horizontal: 10),
isPlaying: isPlaylistPlaying,
isLoading:
(isPlaylistPlaying && isFetchingActiveTrack) || updating.value,
title: album.name!,
description:
"${album.albumType?.formatted}${album.artists?.asString() ?? ""}",
onTap: () {
);
var isLoading =
(isPlaylistPlaying && isFetchingActiveTrack) || updating.value;
var description =
"${album.albumType?.formatted}${album.artists?.asString() ?? ""}";
void onTap() {
ServiceUtils.pushNamed(
context,
AlbumPage.name,
@ -75,8 +77,9 @@ class AlbumCard extends HookConsumerWidget {
},
extra: album,
);
},
onPlaybuttonPressed: () async {
}
void onPlaybuttonPressed() async {
updating.value = true;
try {
if (isPlaylistPlaying) {
@ -104,8 +107,9 @@ class AlbumCard extends HookConsumerWidget {
} finally {
updating.value = false;
}
},
onAddToQueuePressed: () async {
}
void onAddToQueuePressed() async {
if (isPlaylistPlaying) {
return;
}
@ -119,24 +123,53 @@ class AlbumCard extends HookConsumerWidget {
playlistNotifier.addCollection(album.id!);
historyNotifier.addAlbums([album]);
if (context.mounted) {
final snackbar = SnackBar(
showToast(
context: context,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
content: Text(
context.l10n.added_to_queue(fetchedTracks.length),
),
action: SnackBarAction(
label: "Undo",
trailing: Button.outline(
child: Text(context.l10n.undo),
onPressed: () {
playlistNotifier
.removeTracks(fetchedTracks.map((e) => e.id!));
},
),
),
);
},
);
scaffoldMessenger?.showSnackBar(snackbar);
}
} finally {
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/waypoint.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/spotify/spotify.dart';
@ -78,39 +76,17 @@ class UserAlbums extends HookConsumerWidget {
const SliverGap(10),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: SliverGrid.builder(
itemCount: albums.isEmpty ? 6 : albums.length + 1,
gridDelegate:
const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
mainAxisExtent: 225,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemBuilder: (context, index) {
if (albums.isNotEmpty && index == albums.length) {
if (albumsQuery.asData?.value.hasMore != true) {
return const SizedBox.shrink();
}
return Waypoint(
sliver: PlaybuttonView(
controller: controller,
isGrid: true,
onTouchEdge: albumsQueryNotifier.fetchMore,
child: Skeletonizer(
enabled: true,
child: AlbumCard(FakeData.albumSimple),
itemCount: albums.length,
hasMore: albumsQuery.asData?.value.hasMore == true,
isLoading: albumsQuery.isLoading,
onRequestMore: albumsQueryNotifier.fetchMore,
gridItemBuilder: (context, index) => AlbumCard(
albums[index],
),
);
}
return Skeletonizer(
enabled: albumsQuery.isLoading,
child: AlbumCard(
albums.elementAtOrNull(index) ?? FakeData.albumSimple,
),
);
},
listItemBuilder: (context, index) =>
AlbumCard.tile(albums[index]),
),
),
],

View File

@ -5,16 +5,14 @@ import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Image;
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/fallbacks/anonymous_fallback.dart';
import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/components/waypoint.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
import 'package:spotube/provider/authentication/authentication.dart';
@ -127,35 +125,17 @@ class UserPlaylists extends HookConsumerWidget {
const SliverGap(10),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 8),
sliver: SliverGrid.builder(
itemCount: playlists.isEmpty ? 6 : playlists.length + 1,
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
mainAxisExtent: 225,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemBuilder: (context, index) {
if (playlists.isNotEmpty && index == playlists.length) {
if (playlistsQuery.asData?.value.hasMore != true) {
return const SizedBox.shrink();
}
return Waypoint(
sliver: PlaybuttonView(
controller: controller,
isGrid: true,
onTouchEdge: playlistsQueryNotifier.fetchMore,
child: Skeletonizer(
enabled: true,
child: PlaylistCard(FakeData.playlistSimple),
),
);
}
return PlaylistCard(
playlists.elementAtOrNull(index) ??
FakeData.playlistSimple,
);
hasMore: playlistsQuery.asData?.value.hasMore == true,
isLoading: playlistsQuery.isLoading,
onRequestMore: playlistsQueryNotifier.fetchMore,
itemCount: playlists.length,
gridItemBuilder: (context, index) {
return PlaylistCard(playlists[index]);
},
listItemBuilder: (context, index) {
return PlaylistCard.tile(playlists[index]);
},
),
),

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/playbutton_card.dart';
import 'package:spotube/components/playbutton_view/playbutton_card.dart';
import 'package:spotube/components/playbutton_view/playbutton_tile.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/connect/connect.dart';
@ -18,10 +19,18 @@ import 'package:spotube/utils/service_utils.dart';
class PlaylistCard extends HookConsumerWidget {
final PlaylistSimple playlist;
final bool _isTile;
const PlaylistCard(
this.playlist, {
super.key,
});
}) : _isTile = false;
const PlaylistCard.tile(
this.playlist, {
super.key,
}) : _isTile = true;
@override
Widget build(BuildContext context, ref) {
final playlistQueue = ref.watch(audioPlayerProvider);
@ -60,18 +69,7 @@ class PlaylistCard extends HookConsumerWidget {
return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll();
}
return PlaybuttonCard(
margin: const EdgeInsets.symmetric(horizontal: 10),
title: playlist.name!,
description: playlist.description,
imageUrl: playlist.images.asUrlString(
placeholder: ImagePlaceholder.collection,
),
isPlaying: isPlaylistPlaying,
isLoading: (isPlaylistPlaying && isFetchingActiveTrack) || updating.value,
isOwner: playlist.owner?.id == me.asData?.value.id &&
me.asData?.value.id != null,
onTap: () {
void onTap() {
ServiceUtils.pushNamed(
context,
PlaylistPage.name,
@ -80,8 +78,9 @@ class PlaylistCard extends HookConsumerWidget {
},
extra: playlist,
);
},
onPlaybuttonPressed: () async {
}
void onPlaybuttonPressed() async {
try {
updating.value = true;
if (isPlaylistPlaying && playing) {
@ -119,8 +118,9 @@ class PlaylistCard extends HookConsumerWidget {
updating.value = false;
}
}
},
onAddToQueuePressed: () async {
}
void onAddToQueuePressed() async {
updating.value = true;
try {
if (isPlaylistPlaying) return;
@ -133,23 +133,64 @@ class PlaylistCard extends HookConsumerWidget {
playlistNotifier.addCollection(playlist.id!);
historyNotifier.addPlaylists([playlist]);
if (context.mounted) {
final snackbar = SnackBar(
content: Text(context.l10n
.added_num_tracks_to_queue(fetchedInitialTracks.length)),
action: SnackBarAction(
label: "Undo",
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!));
},
),
),
);
},
);
ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar);
}
} 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(
extraActions: [
Tooltip(
tooltip: Text(context.l10n.mini_player),
tooltip:
TooltipContainer(child: Text(context.l10n.mini_player)),
child: IconButton(
variance: ButtonVariance.ghost,
icon: const Icon(SpotubeIcons.miniPlayer),

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:shadcn_flutter/shadcn_flutter.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/modules/album/album_card.dart';
import 'package:spotube/modules/artist/artist_card.dart';
import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/provider/spotify/views/home_section.dart';
class HomeFeedSectionPage extends HookConsumerWidget {
@ -19,39 +20,63 @@ class HomeFeedSectionPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) {
final homeFeedSection = ref.watch(homeSectionViewProvider(sectionUri));
final section = homeFeedSection.asData?.value ?? FakeData.feedSection;
final controller = useScrollController();
final isArtist = section.items.every((item) => item.artist != null);
return Skeletonizer(
enabled: homeFeedSection.isLoading,
child: Scaffold(
appBar: TitleBar(
headers: [
TitleBar(
title: Text(section.title ?? ""),
automaticallyImplyLeading: true,
),
body: CustomScrollView(
)
],
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: CustomScrollView(
controller: controller,
slivers: [
SliverLayoutBuilder(
builder: (context, constrains) {
return SliverGrid.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
if (isArtist)
SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisExtent: constrains.smAndDown ? 225 : 250,
mainAxisExtent: 250,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: section.items.length,
itemBuilder: (context, index) {
final item = section.items[index];
return ArtistCard(item.artist!.asArtist);
},
)
else
PlaybuttonView(
controller: controller,
itemCount: section.items.length,
hasMore: false,
isLoading: false,
onRequestMore: () => {},
listItemBuilder: (context, index) {
final item = section.items[index];
if (item.album != null) {
return AlbumCard.tile(item.album!.asAlbum);
}
if (item.playlist != null) {
return PlaylistCard.tile(item.playlist!.asPlaylist);
}
return const SizedBox.shrink();
},
gridItemBuilder: (context, index) {
final item = section.items[index];
if (item.album != null) {
return AlbumCard(item.album!.asAlbum);
} else if (item.artist != null) {
return ArtistCard(item.artist!.asArtist);
} else if (item.playlist != null) {
}
if (item.playlist != null) {
return PlaylistCard(item.playlist!.asPlaylist);
}
return const SizedBox();
},
);
return const SizedBox.shrink();
},
),
const SliverToBoxAdapter(
@ -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:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/button/back_button.dart';
import 'package:spotube/components/playbutton_view/playbutton_view.dart';
import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart';
import 'package:spotube/modules/playlist/playlist_card.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/waypoint.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:collection/collection.dart';
import 'package:spotube/utils/platform.dart';
class GenrePlaylistsPage extends HookConsumerWidget {
@ -39,33 +40,37 @@ class GenrePlaylistsPage extends HookConsumerWidget {
);
return Scaffold(
appBar: kIsDesktop
? const TitleBar(
leading: [BackButton(color: Colors.white)],
headers: [
if (kIsDesktop)
const TitleBar(
leading: [
BackButton(),
],
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
surfaceOpacity: 0,
surfaceBlur: 0,
)
: null,
extendBodyBehindAppBar: true,
body: DecoratedBox(
],
floatingHeader: true,
child: DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(category.icons!.first.url!),
alignment: Alignment.topCenter,
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(
Colors.black.withOpacity(0.5),
BlendMode.darken,
),
repeat: ImageRepeat.noRepeat,
matchTextDirection: true,
),
),
child: SurfaceCard(
borderRadius: BorderRadius.zero,
padding: EdgeInsets.zero,
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverAppBar(
automaticallyImplyLeading: kIsMobile,
automaticallyImplyLeading: false,
leading: kIsMobile ? const BackButton() : null,
expandedHeight: mediaQuery.mdAndDown ? 200 : 150,
title: const Text(""),
backgroundColor: Colors.transparent,
@ -73,25 +78,25 @@ class GenrePlaylistsPage extends HookConsumerWidget {
centerTitle: kIsDesktop,
title: Text(
category.name!,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
style: context.theme.typography.h3.copyWith(
color: Colors.white,
letterSpacing: 3,
shadows: [
const Shadow(
offset: Offset(-1.5, -1.5),
color: Colors.black54,
Shadow(
offset: const Offset(-1.5, -1.5),
color: Colors.black.withAlpha(138),
),
const Shadow(
offset: Offset(1.5, -1.5),
color: Colors.black54,
Shadow(
offset: const Offset(1.5, -1.5),
color: Colors.black.withAlpha(138),
),
const Shadow(
offset: Offset(1.5, 1.5),
color: Colors.black54,
Shadow(
offset: const Offset(1.5, 1.5),
color: Colors.black.withAlpha(138),
),
const Shadow(
offset: Offset(-1.5, 1.5),
color: Colors.black54,
Shadow(
offset: const Offset(-1.5, 1.5),
color: Colors.black.withAlpha(138),
),
],
),
@ -106,51 +111,16 @@ class GenrePlaylistsPage extends HookConsumerWidget {
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(
sliver: PlaybuttonView(
controller: scrollController,
isGrid: true,
onTouchEdge: playlistsNotifier.fetchMore,
child: PlaylistCard(FakeData.playlist),
),
);
}
return Skeleton.keep(
child: PlaylistCard(playlist),
);
},
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]),
),
),
),
@ -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"
]
}