diff --git a/lib/components/track_presentation/track_presentation.dart b/lib/components/track_presentation/track_presentation.dart index 4f1db832..47089bd6 100644 --- a/lib/components/track_presentation/track_presentation.dart +++ b/lib/components/track_presentation/track_presentation.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart' show ListTile; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -21,9 +20,6 @@ class TrackPresentation extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final headerTextStyle = context.theme.typography.small.copyWith( - color: context.theme.colorScheme.mutedForeground, - ); final scrollController = useScrollController(); final focusNode = useFocusNode(); final scale = context.theme.scaling; @@ -66,10 +62,11 @@ class TrackPresentation extends HookConsumerWidget { TrackPresentationModifiersSection( focusNode: focusNode, ), - ListTile( - titleTextStyle: headerTextStyle, - subtitleTextStyle: headerTextStyle, - leadingAndTrailingTextStyle: headerTextStyle, + Basic( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), leading: constrains.mdAndUp ? const Text(" #") : null, title: Row( children: [ @@ -85,7 +82,7 @@ class TrackPresentation extends HookConsumerWidget { Text(context.l10n.duration), ], ), - ), + ).small().muted(), ], ); }, diff --git a/lib/components/track_presentation/use_track_tile_play_callback.dart b/lib/components/track_presentation/use_track_tile_play_callback.dart index 74608205..b519f781 100644 --- a/lib/components/track_presentation/use_track_tile_play_callback.dart +++ b/lib/components/track_presentation/use_track_tile_play_callback.dart @@ -32,7 +32,11 @@ Future Function(Track track, int index) useTrackTilePlayCallback( ref.read(presentationStateProvider(options.collection).notifier); if (state.selectedTracks.isNotEmpty) { - notifier.selectTrack(track); + if (state.selectedTracks.contains(track)) { + notifier.deselectTrack(track); + } else { + notifier.selectTrack(track); + } return; } diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index 560d2255..0ca14979 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart' show ListTile, Material, MaterialType; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -14,7 +14,9 @@ import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/links/link_text.dart'; import 'package:spotube/components/track_tile/track_options.dart'; +import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/button_variance.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; @@ -89,225 +91,232 @@ class TrackTile extends HookConsumerWidget { }, child: HoverBuilder( permanentState: isSelected || constrains.smAndDown ? true : null, - builder: (context, isHovering) => Material( - type: MaterialType.transparency, - child: ListTile( - selectedColor: theme.colorScheme.primary, - selectedTileColor: theme.colorScheme.primary.withOpacity(0.1), - selected: isSelected, - onTap: () async { - try { - isLoading.value = true; - await onTap?.call(); - } finally { - if (context.mounted) { - isLoading.value = false; - } + builder: (context, isHovering) => ButtonTile( + selected: isSelected, + onPressed: () async { + if (isBlackListed) return; + try { + isLoading.value = true; + await onTap?.call(); + } finally { + if (context.mounted) { + isLoading.value = false; } - }, - onLongPress: onLongPress, - enabled: !isBlackListed, - contentPadding: EdgeInsets.zero, - tileColor: isBlackListed ? theme.colorScheme.destructive : null, - horizontalTitleGap: 12, - leadingAndTrailingTextStyle: theme.typography.normal.copyWith( - color: theme.colorScheme.foreground, - ), - titleTextStyle: theme.typography.normal.copyWith( - color: theme.colorScheme.foreground, - ), - subtitleTextStyle: theme.typography.xSmall.copyWith( - color: theme.colorScheme.mutedForeground, - ), - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...?leadingActions, - AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - crossFadeState: index != null && onChanged == null - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - firstChild: Checkbox( - state: selected - ? CheckboxState.checked - : CheckboxState.unchecked, - onChanged: (state) => - onChanged?.call(state == CheckboxState.checked), - ), - secondChild: constrains.smAndDown - ? const SizedBox(width: 16) - : SizedBox( - width: 50, - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '${(index ?? 0) + 1}', - maxLines: 1, - style: theme.typography.small, - textAlign: TextAlign.center, - ), + } + }, + onLongPress: onLongPress, + style: (isBlackListed + ? ButtonVariance.destructive + : ButtonVariance.ghost) + .copyWith( + padding: (context, states) => + const EdgeInsets.symmetric(vertical: 8, horizontal: 0), + ), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...?leadingActions, + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: index != null && onChanged == null + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: Checkbox( + state: selected + ? CheckboxState.checked + : CheckboxState.unchecked, + onChanged: (state) => + onChanged?.call(state == CheckboxState.checked), + ), + secondChild: constrains.smAndDown + ? const SizedBox(width: 16) + : SizedBox( + width: 50, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '${(index ?? 0) + 1}', + maxLines: 1, + style: theme.typography.small, + textAlign: TextAlign.center, ), ), - ), - Stack( - children: [ - Container( - height: 40, - width: 40, + ), + ), + Stack( + children: [ + Container( + height: 40, + width: 40, + decoration: BoxDecoration( + borderRadius: theme.borderRadiusMd, + image: DecorationImage( + fit: BoxFit.cover, + image: UniversalImage.imageProvider( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + ), + ), + ), + ), + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), decoration: BoxDecoration( borderRadius: theme.borderRadiusMd, - image: DecorationImage( - fit: BoxFit.cover, - image: UniversalImage.imageProvider( - (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, + color: isHovering + ? Colors.black.withAlpha(102) + : Colors.transparent, + ), + ), + ), + Positioned.fill( + child: Center( + child: Skeleton.ignore( + child: Consumer( + builder: (context, ref, _) { + final isFetchingActiveTrack = + ref.watch(queryingTrackInfoProvider); + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: switch (( + isPlaying, + isFetchingActiveTrack, + isPlaying, + isHovering, + isLoading.value + )) { + (true, true, _, _, _) || + (_, _, _, _, true) => + const SizedBox( + width: 26, + height: 26, + child: + CircularProgressIndicator(size: 1.5), + ), + (_, _, true, _, _) => Icon( + SpotubeIcons.pause, + color: theme.colorScheme.primary, + ), + (_, _, _, true, _) => const Icon( + SpotubeIcons.play, + color: Colors.white, + ), + _ => const SizedBox.shrink(), + }, + ); + }, + ), + ), + ), + ), + ], + ), + ], + ), + title: Row( + children: [ + Expanded( + flex: 6, + child: switch (track) { + LocalTrack() => Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _ => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Button( + style: ButtonVariance.link.copyWith( + padding: (context, states) => EdgeInsets.zero, ), - ), - ), - ), - ), - Positioned.fill( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - decoration: BoxDecoration( - borderRadius: theme.borderRadiusMd, - color: isHovering - ? Colors.black.withOpacity(0.4) - : Colors.transparent, - ), - ), - ), - Positioned.fill( - child: Center( - child: Skeleton.ignore( - child: Consumer( - builder: (context, ref, _) { - final isFetchingActiveTrack = - ref.watch(queryingTrackInfoProvider); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: switch (( - isPlaying, - isFetchingActiveTrack, - isPlaying, - isHovering, - isLoading.value - )) { - (true, true, _, _, _) || - (_, _, _, _, true) => - const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator( - size: 1.5), - ), - (_, _, true, _, _) => Icon( - SpotubeIcons.pause, - color: theme.colorScheme.primary, - ), - (_, _, _, true, _) => const Icon( - SpotubeIcons.play, - color: Colors.white, - ), - _ => const SizedBox.shrink(), + onPressed: () { + context.pushNamed( + TrackPage.name, + pathParameters: { + "id": track.id!, }, ); }, + child: Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), ), - ), + ], ), - ], - ), - ], - ), - title: Row( - children: [ + }, + ), + if (constrains.mdAndUp) ...[ + const SizedBox(width: 8), Expanded( - flex: 6, + flex: 4, child: switch (track) { LocalTrack() => Text( - track.name!, + track.album!.name!, maxLines: 1, overflow: TextOverflow.ellipsis, ), - _ => LinkText( - track.name!, - "/track/${track.id}", - push: true, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - }, - ), - if (constrains.mdAndUp) ...[ - const SizedBox(width: 8), - Expanded( - flex: 4, - child: switch (track) { - LocalTrack() => Text( + _ => Align( + alignment: Alignment.centerLeft, + child: LinkText( track.album!.name!, - maxLines: 1, + "/album/${track.album?.id}", + extra: track.album, + push: true, overflow: TextOverflow.ellipsis, ), - _ => Align( - alignment: Alignment.centerLeft, - child: LinkText( - track.album!.name!, - "/album/${track.album?.id}", - extra: track.album, - push: true, - overflow: TextOverflow.ellipsis, - ), - ) - }, - ), - ], + ) + }, + ), ], - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: track is LocalTrack - ? Text( - track.artists?.asString() ?? '', - ) - : ClipRect( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 40), - child: ArtistLink( - artists: track.artists ?? [], - onOverflowArtistClick: () => ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track.id!, - }, - ), + ], + ), + subtitle: Align( + alignment: Alignment.centerLeft, + child: track is LocalTrack + ? Text( + track.artists?.asString() ?? '', + ) + : ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 40), + child: ArtistLink( + artists: track.artists ?? [], + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, ), ), ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 8), - Text( - Duration(milliseconds: track.durationMs ?? 0) - .toHumanReadableString(padZero: false), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - TrackOptions( - track: track, - playlistId: playlistId, - userPlaylist: userPlaylist, - showMenuCbRef: showOptionCbRef, - ), - if (kIsDesktop) const Gap(10), - ], - ), + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + Duration(milliseconds: track.durationMs ?? 0) + .toHumanReadableString(padZero: false), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + TrackOptions( + track: track, + playlistId: playlistId, + userPlaylist: userPlaylist, + showMenuCbRef: showOptionCbRef, + ), + if (kIsDesktop) const Gap(10), + ], ), ), ), diff --git a/lib/components/ui/button_tile.dart b/lib/components/ui/button_tile.dart index 7318e8c4..8f5a7581 100644 --- a/lib/components/ui/button_tile.dart +++ b/lib/components/ui/button_tile.dart @@ -6,7 +6,8 @@ class ButtonTile extends StatelessWidget { final Widget? leading; final Widget? trailing; final bool enabled; - final void Function()? onPressed; + final VoidCallback? onPressed; + final VoidCallback? onLongPress; final bool selected; final ButtonVariance style; final EdgeInsets? padding; @@ -19,6 +20,7 @@ class ButtonTile extends StatelessWidget { this.trailing, this.enabled = true, this.onPressed, + this.onLongPress, this.selected = false, this.padding, this.style = ButtonVariance.outline, @@ -28,73 +30,78 @@ class ButtonTile extends StatelessWidget { Widget build(BuildContext context) { final ThemeData(:colorScheme, :typography) = Theme.of(context); - return Button( - enabled: enabled, - onPressed: onPressed, - style: style.copyWith( - padding: padding != null ? (context, states, value) => padding! : null, - decoration: (context, states, value) { - final decoration = style.decoration(context, states) as BoxDecoration; + return GestureDetector( + onLongPress: onLongPress, + child: Button( + enabled: enabled, + onPressed: onPressed, + style: style.copyWith( + padding: + padding != null ? (context, states, value) => padding! : null, + decoration: (context, states, value) { + final decoration = + style.decoration(context, states) as BoxDecoration; - if (selected) { - return switch (style) { - ButtonVariance.outline => decoration.copyWith( - border: Border.all( - color: colorScheme.primary, - width: 1.0, + if (selected) { + return switch (style) { + ButtonVariance.outline => decoration.copyWith( + border: Border.all( + color: colorScheme.primary, + width: 1.0, + ), + color: colorScheme.primary.withAlpha(25), ), - color: colorScheme.primary.withAlpha(25), - ), - ButtonVariance.ghost || _ => decoration.copyWith( - color: colorScheme.primary.withAlpha(25), - ), - }; - } + ButtonVariance.ghost || _ => decoration.copyWith( + color: colorScheme.primary.withAlpha(25), + ), + }; + } - return decoration; - }, - iconTheme: (context, states, value) { - final iconTheme = style.iconTheme(context, states); + return decoration; + }, + iconTheme: (context, states, value) { + final iconTheme = style.iconTheme(context, states); - if (selected && style == ButtonVariance.outline) { - return iconTheme.copyWith( - color: colorScheme.primary, - ); - } + if (selected && style == ButtonVariance.outline) { + return iconTheme.copyWith( + color: colorScheme.primary, + ); + } - return iconTheme; - }, - textStyle: (context, states, value) { - final textStyle = style.textStyle(context, states); + return iconTheme; + }, + textStyle: (context, states, value) { + final textStyle = style.textStyle(context, states); - if (selected && style == ButtonVariance.outline) { - return textStyle.copyWith( - color: colorScheme.primary, - ); - } + if (selected && style == ButtonVariance.outline) { + return textStyle.copyWith( + color: colorScheme.primary, + ); + } - return textStyle; - }, - ), - alignment: Alignment.centerLeft, - child: SizedBox( - width: double.infinity, - child: Basic( - padding: EdgeInsets.zero, - leadingAlignment: Alignment.center, - trailingAlignment: Alignment.center, - leading: leading, - title: title, - subtitle: - style == ButtonVariance.outline && selected && subtitle != null - ? DefaultTextStyle( - style: typography.xSmall.copyWith( - color: colorScheme.primary, - ), - child: subtitle!, - ) - : subtitle, - trailing: trailing, + return textStyle; + }, + ), + alignment: Alignment.centerLeft, + child: SizedBox( + width: double.infinity, + child: Basic( + padding: EdgeInsets.zero, + leadingAlignment: Alignment.center, + trailingAlignment: Alignment.center, + leading: leading, + title: title, + subtitle: + style == ButtonVariance.outline && selected && subtitle != null + ? DefaultTextStyle( + style: typography.xSmall.copyWith( + color: colorScheme.primary, + ), + child: subtitle!, + ) + : subtitle, + trailing: trailing, + ), ), ), ); diff --git a/lib/extensions/button_variance.dart b/lib/extensions/button_variance.dart new file mode 100644 index 00000000..cf66d528 --- /dev/null +++ b/lib/extensions/button_variance.dart @@ -0,0 +1,21 @@ +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +extension CopyWithButtonVarianceExtension on ButtonVariance { + ButtonVariance copyWith({ + ButtonStateProperty? padding, + ButtonStateProperty? decoration, + ButtonStateProperty? mouseCursor, + ButtonStateProperty? iconTheme, + ButtonStateProperty? margin, + ButtonStateProperty? textStyle, + }) { + return ButtonVariance( + padding: padding ?? this.padding, + decoration: decoration ?? this.decoration, + mouseCursor: mouseCursor ?? this.mouseCursor, + iconTheme: iconTheme ?? this.iconTheme, + margin: margin ?? this.margin, + textStyle: textStyle ?? this.textStyle, + ); + } +} diff --git a/lib/modules/home/sections/genres.dart b/lib/modules/home/sections/genres.dart index 9309e2e7..b273b970 100644 --- a/lib/modules/home/sections/genres.dart +++ b/lib/modules/home/sections/genres.dart @@ -43,7 +43,7 @@ class HomeGenresSection extends HookConsumerWidget { useEffect(() { int times = 0; - Timer.periodic( + final timer = Timer.periodic( const Duration(seconds: 5), (timer) { if (times > 5 || interactedRef.value) { @@ -57,7 +57,10 @@ class HomeGenresSection extends HookConsumerWidget { }, ); - return controller.dispose; + return () { + timer.cancel(); + controller.dispose(); + }; }, []); return SliverList.list(