extract play button from item card and add long press animation

This commit is contained in:
jeffvli
2025-11-25 16:20:44 -08:00
parent 8ad5e26c2f
commit 2264fa0d29
10 changed files with 296 additions and 226 deletions
@@ -45,44 +45,7 @@
margin-bottom: var(--theme-spacing-lg);
}
.play-button {
all: unset;
position: absolute;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border: none;
border-radius: 100%;
opacity: 0.8;
transform: translate(-50%, -50%) scale(1);
transition: opacity 0.1s ease-in-out;
transition: transform 0.1s ease-in-out;
&:hover {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
&:active {
opacity: 1;
transform: translate(-50%, -50%) scale(0.9);
}
svg {
stroke: rgb(0 0 0);
}
}
.play-button.disabled,
.play-button.loading {
cursor: not-allowed;
opacity: 0.5;
transform: translate(-50%, -50%) scale(1);
}
.play-button.primary {
.primary {
left: 50%;
width: 25%;
height: 25%;
@@ -92,36 +55,16 @@
}
}
.play-button.secondary {
.secondary {
width: 15%;
height: 15%;
}
.play-button.left {
.left {
left: 25%;
}
.play-button.right {
left: 75%;
}
.play-button.left-top {
top: 40%;
left: 25%;
}
.play-button.left-bottom {
top: 60%;
left: 25%;
}
.play-button.right-bottom {
top: 60%;
left: 75%;
}
.play-button.right-top {
top: 40%;
.right {
left: 75%;
}
@@ -130,7 +73,7 @@
position: absolute;
padding: var(--theme-spacing-md);
border-radius: var(--theme-radius-md);
opacity: 0.8;
opacity: 1;
transition: opacity 0.2s ease-in-out;
transition: scale 0.2s linear;
@@ -6,14 +6,13 @@ import styles from './item-card-controls.module.css';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls } from '/@/renderer/components/item-list/types';
import { useIsPlayerFetching } from '/@/renderer/features/player/context/player-context';
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { animationVariants } from '/@/shared/components/animations/animation-variants';
import { AppIcon, Icon, IconProps } from '/@/shared/components/icon/icon';
import { Rating } from '/@/shared/components/rating/rating';
import { useLongPress } from '/@/shared/hooks/use-long-press';
import {
Album,
AlbumArtist,
@@ -236,15 +235,19 @@ export const ItemCardControls = ({
<motion.div className={clsx(styles.container)} {...containerProps[type]}>
{controls?.onPlay && (
<>
<PlayButton onClick={playNowHandler} onLongPress={playShuffleHandler} />
<SecondaryPlayButton
className={styles.left}
<PlayButton
classNames={clsx(styles.primary)}
onClick={playNowHandler}
onLongPress={playShuffleHandler}
/>
<PlayButton
classNames={clsx(styles.secondary, styles.left)}
icon="mediaPlayNext"
onClick={playNextHandler}
onLongPress={playNextShuffleHandler}
/>
<SecondaryPlayButton
className={styles.right}
<PlayButton
classNames={clsx(styles.secondary, styles.right)}
icon="mediaPlayLast"
onClick={playLastHandler}
onLongPress={playLastShuffleHandler}
@@ -340,128 +343,6 @@ const RatingButton = memo(
(prev, next) => prev.rating === next.rating,
);
const PlayButton = memo(
({
loading,
onClick,
onLongPress,
}: {
loading?: boolean;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onLongPress?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) => {
const isPlayerFetching = useIsPlayerFetching();
const disabled = isPlayerFetching || loading;
const longPressHandlers = useLongPress<HTMLButtonElement>({
onClick: (e) => {
if (disabled || loading) {
return;
}
e.stopPropagation();
e.preventDefault();
onClick?.(e as React.MouseEvent<HTMLButtonElement>);
},
onLongPress: (e) => {
if (disabled || loading) {
return;
}
e.stopPropagation();
e.preventDefault();
onLongPress?.(e as React.MouseEvent<HTMLButtonElement>);
},
});
const handleMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
longPressHandlers.onMouseDown?.(e);
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
};
return (
<button
className={clsx(styles.playButton, styles.primary, {
[styles.disabled]: disabled,
})}
disabled={disabled}
onClick={handleClick}
onMouseDown={handleMouseDown}
onMouseLeave={longPressHandlers.onMouseLeave}
onMouseUp={longPressHandlers.onMouseUp}
onTouchCancel={longPressHandlers.onTouchCancel}
onTouchEnd={longPressHandlers.onTouchEnd}
onTouchStart={longPressHandlers.onTouchStart}
>
<Icon icon="mediaPlay" size="lg" />
</button>
);
},
);
const SecondaryPlayButton = memo(
({
className,
icon,
onClick,
onLongPress,
}: {
className?: string;
icon: keyof typeof AppIcon;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onLongPress?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) => {
const isPlayerFetching = useIsPlayerFetching();
const disabled = isPlayerFetching;
const longPressHandlers = useLongPress<HTMLButtonElement>({
onClick: (e) => {
e.stopPropagation();
e.preventDefault();
onClick?.(e as React.MouseEvent<HTMLButtonElement>);
},
onLongPress: (e) => {
e.stopPropagation();
e.preventDefault();
onLongPress?.(e as React.MouseEvent<HTMLButtonElement>);
},
});
const handleMouseDown = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
longPressHandlers.onMouseDown?.(e);
};
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
};
return (
<button
className={clsx(styles.playButton, styles.secondary, className, {
[styles.disabled]: disabled,
})}
disabled={disabled}
onClick={handleClick}
onMouseDown={handleMouseDown}
onMouseLeave={longPressHandlers.onMouseLeave}
onMouseUp={longPressHandlers.onMouseUp}
onTouchCancel={longPressHandlers.onTouchCancel}
onTouchEnd={longPressHandlers.onTouchEnd}
onTouchStart={longPressHandlers.onTouchStart}
>
<Icon icon={icon} size="lg" />
</button>
);
},
);
interface SecondaryButtonProps {
className?: string;
disabled?: boolean;
@@ -12,7 +12,7 @@ import { AnimatedPage } from '/@/renderer/features/shared/components/animated-pa
import { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
import { LibraryHeader } from '/@/renderer/features/shared/components/library-header';
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import { DefaultPlayButton } from '/@/renderer/features/shared/components/play-button';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';
@@ -165,7 +165,7 @@ const DummyAlbumDetailRoute = () => {
<section>
<Group gap="sm" justify="space-between">
<Group>
<PlayButton onClick={() => handlePlay()} />
<DefaultPlayButton onClick={() => handlePlay()} />
<ActionIcon
icon="favorite"
iconProps={{
@@ -10,7 +10,7 @@ import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
import { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import { DefaultPlayButton } from '/@/renderer/features/shared/components/play-button';
import { useContainerQuery } from '/@/renderer/hooks';
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
import { AppRoute } from '/@/renderer/router/routes';
@@ -227,7 +227,7 @@ export const AlbumArtistDetailContent = () => {
<div className={styles.contentContainer} ref={ref}>
<div className={styles.detailContainer}>
<Group gap="md">
<PlayButton
<DefaultPlayButton
disabled={albumCount === 0}
onClick={() => handlePlay(playButtonBehavior)}
/>
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import styles from './library-header-bar.module.css';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
import { DefaultPlayButton } from '/@/renderer/features/shared/components/play-button';
import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
import { useCurrentServerId } from '/@/renderer/store';
import { Badge, BadgeProps } from '/@/shared/components/badge/badge';
@@ -75,7 +75,7 @@ const HeaderPlayButton = ({
return (
<div className={styles.playButtonContainer}>
<PlayButton
<DefaultPlayButton
className={className}
onClick={openPlayTypeModal}
variant={variant}
@@ -6,7 +6,7 @@ import { Link } from 'react-router';
import styles from './library-header.module.css';
import {
WidePlayButton,
PlayTextButton,
WideShuffleButton,
} from '/@/renderer/features/shared/components/play-button';
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
@@ -190,7 +190,7 @@ export const LibraryHeaderMenu = ({
return (
<div className={styles.libraryHeaderMenu}>
<Group wrap="nowrap">
{onPlay && <WidePlayButton onClick={onPlay} />}
{onPlay && <PlayTextButton onClick={onPlay} />}
{onShuffle && <WideShuffleButton onClick={onShuffle} />}
</Group>
<Group gap="sm" wrap="nowrap">
@@ -1,4 +1,4 @@
.button {
.text-button {
width: 3rem;
height: 3rem;
border-radius: 50%;
@@ -15,7 +15,7 @@
}
}
.button.unthemed {
.text-button.unthemed {
@mixin light {
color: white;
background: black;
@@ -45,7 +45,7 @@
}
}
.wide-button {
.wide-text-button {
padding-right: var(--theme-spacing-xl);
padding-left: var(--theme-spacing-xl);
background: white;
@@ -53,7 +53,7 @@
transition: background-color 0.2s ease-in-out;
}
.wide-button.unthemed {
.wide-text-button.unthemed {
@mixin light {
background: black;
@@ -81,7 +81,7 @@
}
}
.wide-button-label {
.wide-text-button-label {
font-size: var(--theme-font-size-md);
font-weight: 600;
color: black;
@@ -91,3 +91,78 @@
fill: black;
}
}
.play-button {
all: unset;
position: absolute;
top: 50%;
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border: none;
border-radius: 100%;
opacity: 1;
--play-button-scale: 1;
--long-press-duration: 500ms;
transform: translate(-50%, -50%) scale(var(--play-button-scale, 1));
transition: opacity 0.1s ease-in-out;
transition: transform 0.1s ease-in-out;
overflow: visible;
isolation: isolate;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
background-color: var(--theme-colors-primary);
border-radius: 50%;
transform: translate(-50%, -50%) scale(0);
opacity: 1;
transition: transform 0.15s ease-out;
z-index: 0;
pointer-events: none;
}
&[data-pressing='true']::before {
animation: expand-long-press var(--long-press-duration) linear 100ms forwards;
transition: none;
}
&:hover {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
&:active {
opacity: 1;
transform: translate(-50%, -50%) scale(0.9);
}
svg {
position: relative;
z-index: 1;
stroke: rgb(0 0 0);
}
}
@keyframes expand-long-press {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 0.2;
}
100% {
transform: translate(-50%, -50%) scale(1.05);
opacity: 0.8;
}
}
.play-button.disabled,
.play-button.loading {
cursor: not-allowed;
opacity: 0.5;
transform: translate(-50%, -50%) scale(1);
}
@@ -1,21 +1,27 @@
import clsx from 'clsx';
import { t } from 'i18next';
import { memo } from 'react';
import styles from './play-button.module.css';
import { usePlayButtonClick } from '/@/renderer/features/shared/hooks/use-play-button-click';
import { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
import { Button, ButtonProps } from '/@/shared/components/button/button';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
export interface PlayButtonProps extends ActionIconProps {
export interface DefaultPlayButtonProps extends ActionIconProps {
size?: number | string;
}
export const PlayButton = ({ className, variant = 'filled', ...props }: PlayButtonProps) => {
export const DefaultPlayButton = ({
className,
variant = 'filled',
...props
}: DefaultPlayButtonProps) => {
return (
<ActionIcon
className={clsx(styles.button, className, {
className={clsx(styles.textButton, className, {
[styles.unthemed]: variant !== 'filled',
})}
icon="mediaPlay"
@@ -28,21 +34,21 @@ export const PlayButton = ({ className, variant = 'filled', ...props }: PlayButt
);
};
interface WidePlayButtonProps extends ButtonProps {}
interface TextPlayButtonProps extends ButtonProps {}
export const WidePlayButton = ({
export const PlayTextButton = ({
className,
variant = 'default',
...props
}: WidePlayButtonProps) => {
}: TextPlayButtonProps) => {
return (
<Button
className={clsx(styles.wideButton, className, {
className={clsx(styles.wideTextButton, className, {
[styles.unthemed]: variant !== 'filled',
})}
classNames={{
label: styles.wideButtonLabel,
root: styles.wideButton,
label: styles.wideTextButtonLabel,
root: styles.wideTextButton,
}}
variant="subtle"
{...props}
@@ -57,13 +63,43 @@ export const WidePlayButton = ({
);
};
export const WideShuffleButton = ({ ...props }: WidePlayButtonProps) => {
export const WideShuffleButton = ({ ...props }: TextPlayButtonProps) => {
return (
<WidePlayButton {...props}>
<PlayTextButton {...props}>
<Group gap="sm" wrap="nowrap">
<Icon fill="default" icon="mediaShuffle" size="lg" />
{t('action.shuffle', { postProcess: 'sentenceCase' })}
</Group>
</WidePlayButton>
</PlayTextButton>
);
};
interface PlayButtonProps {
classNames?: string;
icon?: keyof typeof AppIcon;
loading?: boolean;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onLongPress?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
export const PlayButton = memo(
({ classNames, icon = 'mediaPlay', loading, onClick, onLongPress }: PlayButtonProps) => {
const clickHandlers = usePlayButtonClick({
loading,
onClick,
onLongPress,
});
return (
<button
className={clsx(styles.playButton, classNames)}
{...clickHandlers.handlers}
{...clickHandlers.props}
>
<Icon icon={icon} size="lg" />
</button>
);
},
);
PlayButton.displayName = 'PlayButton';
@@ -0,0 +1,121 @@
import { useCallback, useMemo, useState } from 'react';
import { useIsPlayerFetching } from '/@/renderer/features/player/context/player-context';
import { useLongPress } from '/@/shared/hooks/use-long-press';
interface UsePlayButtonClickOptions {
disabled?: boolean;
loading?: boolean;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onLongPress?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
interface UsePlayButtonClickReturn {
handlers: {
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
onMouseDown: (e: React.MouseEvent<HTMLButtonElement>) => void;
onMouseLeave: (e: React.MouseEvent) => void;
onMouseUp: (e: React.MouseEvent) => void;
onTouchCancel: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
onTouchStart: (e: React.TouchEvent) => void;
};
props: {
'data-pressing'?: string;
disabled: boolean;
style: React.CSSProperties;
};
}
export const usePlayButtonClick = ({
loading,
onClick,
onLongPress,
}: UsePlayButtonClickOptions): UsePlayButtonClickReturn => {
const isPlayerFetching = useIsPlayerFetching();
const isDisabled = Boolean(isPlayerFetching || loading);
const [isPressing, setIsPressing] = useState(false);
const [isLongPressing, setIsLongPressing] = useState(false);
const longPressHandlers = useLongPress<HTMLButtonElement>({
onClick: (e) => {
if (isDisabled || loading) {
return;
}
e.stopPropagation();
e.preventDefault();
onClick?.(e as React.MouseEvent<HTMLButtonElement>);
},
onFinish: () => {
setIsPressing(false);
setIsLongPressing(false);
},
onLongPress: (e) => {
if (isDisabled || loading) {
return;
}
e.stopPropagation();
e.preventDefault();
setIsPressing(false);
setIsLongPressing(true);
onLongPress?.(e as React.MouseEvent<HTMLButtonElement>);
},
onStart: () => {
if (!isDisabled && !loading) {
setIsPressing(true);
setIsLongPressing(false);
}
},
});
const handleMouseDown = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
longPressHandlers.onMouseDown?.(e);
},
[longPressHandlers],
);
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
}, []);
const handlers = useMemo(
() => ({
onClick: handleClick,
onMouseDown: handleMouseDown,
onMouseLeave: longPressHandlers.onMouseLeave,
onMouseUp: longPressHandlers.onMouseUp,
onTouchCancel: longPressHandlers.onTouchCancel,
onTouchEnd: longPressHandlers.onTouchEnd,
onTouchStart: longPressHandlers.onTouchStart,
}),
[
handleClick,
handleMouseDown,
longPressHandlers.onMouseLeave,
longPressHandlers.onMouseUp,
longPressHandlers.onTouchCancel,
longPressHandlers.onTouchEnd,
longPressHandlers.onTouchStart,
],
);
const props = useMemo(() => {
return {
'data-pressing': isPressing ? 'true' : undefined,
disabled: isDisabled,
style: {
'--long-press-duration': '300ms',
'--play-button-scale': isLongPressing ? 1.15 : 1,
opacity: isDisabled ? 0.5 : 1,
transition: 'opacity 0.2s ease-in-out, transform 0.2s ease-in-out',
} as React.CSSProperties,
};
}, [isDisabled, isPressing, isLongPressing]);
return { handlers, props };
};
+23 -9
View File
@@ -3,7 +3,9 @@ import { useCallback, useRef } from 'react';
interface UseLongPressOptions<T extends HTMLElement = HTMLElement> {
delay?: number;
onClick?: (event: React.MouseEvent<T> | React.TouchEvent<T>) => void;
onFinish?: (event: null | React.MouseEvent<T> | React.TouchEvent<T>) => void;
onLongPress?: (event: React.MouseEvent<T> | React.TouchEvent<T>) => void;
onStart?: (event: React.MouseEvent<T> | React.TouchEvent<T>) => void;
}
interface UseLongPressReturn {
@@ -18,7 +20,9 @@ interface UseLongPressReturn {
export const useLongPress = <T extends HTMLElement = HTMLElement>({
delay = 500,
onClick,
onFinish,
onLongPress,
onStart,
}: UseLongPressOptions<T>): UseLongPressReturn => {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const targetRef = useRef<EventTarget | null>(null);
@@ -31,6 +35,8 @@ export const useLongPress = <T extends HTMLElement = HTMLElement>({
targetRef.current = event.target;
eventRef.current = event;
onStart?.(event);
timeoutRef.current = setTimeout(() => {
longPressTriggeredRef.current = true;
if (eventRef.current) {
@@ -38,7 +44,7 @@ export const useLongPress = <T extends HTMLElement = HTMLElement>({
}
}, delay);
},
[onLongPress, delay],
[onLongPress, onStart, delay],
);
const clear = useCallback(() => {
@@ -57,19 +63,23 @@ export const useLongPress = <T extends HTMLElement = HTMLElement>({
);
const handleMouseUp = useCallback(() => {
const event = eventRef.current;
clear();
if (!longPressTriggeredRef.current && onClick && eventRef.current) {
onClick(eventRef.current);
if (!longPressTriggeredRef.current && onClick && event) {
onClick(event);
}
onFinish?.(event || null);
longPressTriggeredRef.current = false;
eventRef.current = null;
}, [clear, onClick]);
}, [clear, onClick, onFinish]);
const handleMouseLeave = useCallback(() => {
const event = eventRef.current;
clear();
onFinish?.(event || null);
longPressTriggeredRef.current = false;
eventRef.current = null;
}, [clear]);
}, [clear, onFinish]);
const handleTouchStart = useCallback(
(event: React.TouchEvent) => {
@@ -79,19 +89,23 @@ export const useLongPress = <T extends HTMLElement = HTMLElement>({
);
const handleTouchEnd = useCallback(() => {
const event = eventRef.current;
clear();
if (!longPressTriggeredRef.current && onClick && eventRef.current) {
onClick(eventRef.current);
if (!longPressTriggeredRef.current && onClick && event) {
onClick(event);
}
onFinish?.(event || null);
longPressTriggeredRef.current = false;
eventRef.current = null;
}, [clear, onClick]);
}, [clear, onClick, onFinish]);
const handleTouchCancel = useCallback(() => {
const event = eventRef.current;
clear();
onFinish?.(event || null);
longPressTriggeredRef.current = false;
eventRef.current = null;
}, [clear]);
}, [clear, onFinish]);
return {
onMouseDown: handleMouseDown,