mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
extract play button from item card and add long press animation
This commit is contained in:
@@ -45,44 +45,7 @@
|
|||||||
margin-bottom: var(--theme-spacing-lg);
|
margin-bottom: var(--theme-spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-button {
|
.primary {
|
||||||
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 {
|
|
||||||
left: 50%;
|
left: 50%;
|
||||||
width: 25%;
|
width: 25%;
|
||||||
height: 25%;
|
height: 25%;
|
||||||
@@ -92,36 +55,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-button.secondary {
|
.secondary {
|
||||||
width: 15%;
|
width: 15%;
|
||||||
height: 15%;
|
height: 15%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-button.left {
|
.left {
|
||||||
left: 25%;
|
left: 25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-button.right {
|
.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%;
|
|
||||||
left: 75%;
|
left: 75%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +73,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
padding: var(--theme-spacing-md);
|
padding: var(--theme-spacing-md);
|
||||||
border-radius: var(--theme-radius-md);
|
border-radius: var(--theme-radius-md);
|
||||||
opacity: 0.8;
|
opacity: 1;
|
||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
transition: scale 0.2s linear;
|
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 { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
|
||||||
import { ItemControls } from '/@/renderer/components/item-list/types';
|
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 { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||||
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
import { useIsMutatingDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||||
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
|
import { useIsMutatingRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
|
||||||
import { animationVariants } from '/@/shared/components/animations/animation-variants';
|
import { animationVariants } from '/@/shared/components/animations/animation-variants';
|
||||||
import { AppIcon, Icon, IconProps } from '/@/shared/components/icon/icon';
|
import { AppIcon, Icon, IconProps } from '/@/shared/components/icon/icon';
|
||||||
import { Rating } from '/@/shared/components/rating/rating';
|
import { Rating } from '/@/shared/components/rating/rating';
|
||||||
import { useLongPress } from '/@/shared/hooks/use-long-press';
|
|
||||||
import {
|
import {
|
||||||
Album,
|
Album,
|
||||||
AlbumArtist,
|
AlbumArtist,
|
||||||
@@ -236,15 +235,19 @@ export const ItemCardControls = ({
|
|||||||
<motion.div className={clsx(styles.container)} {...containerProps[type]}>
|
<motion.div className={clsx(styles.container)} {...containerProps[type]}>
|
||||||
{controls?.onPlay && (
|
{controls?.onPlay && (
|
||||||
<>
|
<>
|
||||||
<PlayButton onClick={playNowHandler} onLongPress={playShuffleHandler} />
|
<PlayButton
|
||||||
<SecondaryPlayButton
|
classNames={clsx(styles.primary)}
|
||||||
className={styles.left}
|
onClick={playNowHandler}
|
||||||
|
onLongPress={playShuffleHandler}
|
||||||
|
/>
|
||||||
|
<PlayButton
|
||||||
|
classNames={clsx(styles.secondary, styles.left)}
|
||||||
icon="mediaPlayNext"
|
icon="mediaPlayNext"
|
||||||
onClick={playNextHandler}
|
onClick={playNextHandler}
|
||||||
onLongPress={playNextShuffleHandler}
|
onLongPress={playNextShuffleHandler}
|
||||||
/>
|
/>
|
||||||
<SecondaryPlayButton
|
<PlayButton
|
||||||
className={styles.right}
|
classNames={clsx(styles.secondary, styles.right)}
|
||||||
icon="mediaPlayLast"
|
icon="mediaPlayLast"
|
||||||
onClick={playLastHandler}
|
onClick={playLastHandler}
|
||||||
onLongPress={playLastShuffleHandler}
|
onLongPress={playLastShuffleHandler}
|
||||||
@@ -340,128 +343,6 @@ const RatingButton = memo(
|
|||||||
(prev, next) => prev.rating === next.rating,
|
(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 {
|
interface SecondaryButtonProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
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 { LibraryContainer } from '/@/renderer/features/shared/components/library-container';
|
||||||
import { LibraryHeader } from '/@/renderer/features/shared/components/library-header';
|
import { LibraryHeader } from '/@/renderer/features/shared/components/library-header';
|
||||||
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
|
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 { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||||
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
|
||||||
import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';
|
import { useContainerQuery, useFastAverageColor } from '/@/renderer/hooks';
|
||||||
@@ -165,7 +165,7 @@ const DummyAlbumDetailRoute = () => {
|
|||||||
<section>
|
<section>
|
||||||
<Group gap="sm" justify="space-between">
|
<Group gap="sm" justify="space-between">
|
||||||
<Group>
|
<Group>
|
||||||
<PlayButton onClick={() => handlePlay()} />
|
<DefaultPlayButton onClick={() => handlePlay()} />
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="favorite"
|
icon="favorite"
|
||||||
iconProps={{
|
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 { AlbumArtistGridCarousel } from '/@/renderer/features/artists/components/album-artist-grid-carousel';
|
||||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
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 { useContainerQuery } from '/@/renderer/hooks';
|
||||||
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
import { useGenreRoute } from '/@/renderer/hooks/use-genre-route';
|
||||||
import { AppRoute } from '/@/renderer/router/routes';
|
import { AppRoute } from '/@/renderer/router/routes';
|
||||||
@@ -227,7 +227,7 @@ export const AlbumArtistDetailContent = () => {
|
|||||||
<div className={styles.contentContainer} ref={ref}>
|
<div className={styles.contentContainer} ref={ref}>
|
||||||
<div className={styles.detailContainer}>
|
<div className={styles.detailContainer}>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
<PlayButton
|
<DefaultPlayButton
|
||||||
disabled={albumCount === 0}
|
disabled={albumCount === 0}
|
||||||
onClick={() => handlePlay(playButtonBehavior)}
|
onClick={() => handlePlay(playButtonBehavior)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import styles from './library-header-bar.module.css';
|
import styles from './library-header-bar.module.css';
|
||||||
|
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
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 { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group';
|
||||||
import { useCurrentServerId } from '/@/renderer/store';
|
import { useCurrentServerId } from '/@/renderer/store';
|
||||||
import { Badge, BadgeProps } from '/@/shared/components/badge/badge';
|
import { Badge, BadgeProps } from '/@/shared/components/badge/badge';
|
||||||
@@ -75,7 +75,7 @@ const HeaderPlayButton = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.playButtonContainer}>
|
<div className={styles.playButtonContainer}>
|
||||||
<PlayButton
|
<DefaultPlayButton
|
||||||
className={className}
|
className={className}
|
||||||
onClick={openPlayTypeModal}
|
onClick={openPlayTypeModal}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Link } from 'react-router';
|
|||||||
import styles from './library-header.module.css';
|
import styles from './library-header.module.css';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
WidePlayButton,
|
PlayTextButton,
|
||||||
WideShuffleButton,
|
WideShuffleButton,
|
||||||
} from '/@/renderer/features/shared/components/play-button';
|
} from '/@/renderer/features/shared/components/play-button';
|
||||||
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
import { useIsMutatingCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
|
||||||
@@ -190,7 +190,7 @@ export const LibraryHeaderMenu = ({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.libraryHeaderMenu}>
|
<div className={styles.libraryHeaderMenu}>
|
||||||
<Group wrap="nowrap">
|
<Group wrap="nowrap">
|
||||||
{onPlay && <WidePlayButton onClick={onPlay} />}
|
{onPlay && <PlayTextButton onClick={onPlay} />}
|
||||||
{onShuffle && <WideShuffleButton onClick={onShuffle} />}
|
{onShuffle && <WideShuffleButton onClick={onShuffle} />}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.button {
|
.text-button {
|
||||||
width: 3rem;
|
width: 3rem;
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.unthemed {
|
.text-button.unthemed {
|
||||||
@mixin light {
|
@mixin light {
|
||||||
color: white;
|
color: white;
|
||||||
background: black;
|
background: black;
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wide-button {
|
.wide-text-button {
|
||||||
padding-right: var(--theme-spacing-xl);
|
padding-right: var(--theme-spacing-xl);
|
||||||
padding-left: var(--theme-spacing-xl);
|
padding-left: var(--theme-spacing-xl);
|
||||||
background: white;
|
background: white;
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
transition: background-color 0.2s ease-in-out;
|
transition: background-color 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wide-button.unthemed {
|
.wide-text-button.unthemed {
|
||||||
@mixin light {
|
@mixin light {
|
||||||
background: black;
|
background: black;
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wide-button-label {
|
.wide-text-button-label {
|
||||||
font-size: var(--theme-font-size-md);
|
font-size: var(--theme-font-size-md);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: black;
|
color: black;
|
||||||
@@ -91,3 +91,78 @@
|
|||||||
fill: black;
|
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 clsx from 'clsx';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import styles from './play-button.module.css';
|
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 { ActionIcon, ActionIconProps } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Button, ButtonProps } from '/@/shared/components/button/button';
|
import { Button, ButtonProps } from '/@/shared/components/button/button';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
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;
|
size?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayButton = ({ className, variant = 'filled', ...props }: PlayButtonProps) => {
|
export const DefaultPlayButton = ({
|
||||||
|
className,
|
||||||
|
variant = 'filled',
|
||||||
|
...props
|
||||||
|
}: DefaultPlayButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
className={clsx(styles.button, className, {
|
className={clsx(styles.textButton, className, {
|
||||||
[styles.unthemed]: variant !== 'filled',
|
[styles.unthemed]: variant !== 'filled',
|
||||||
})}
|
})}
|
||||||
icon="mediaPlay"
|
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,
|
className,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
...props
|
...props
|
||||||
}: WidePlayButtonProps) => {
|
}: TextPlayButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={clsx(styles.wideButton, className, {
|
className={clsx(styles.wideTextButton, className, {
|
||||||
[styles.unthemed]: variant !== 'filled',
|
[styles.unthemed]: variant !== 'filled',
|
||||||
})}
|
})}
|
||||||
classNames={{
|
classNames={{
|
||||||
label: styles.wideButtonLabel,
|
label: styles.wideTextButtonLabel,
|
||||||
root: styles.wideButton,
|
root: styles.wideTextButton,
|
||||||
}}
|
}}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
{...props}
|
{...props}
|
||||||
@@ -57,13 +63,43 @@ export const WidePlayButton = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WideShuffleButton = ({ ...props }: WidePlayButtonProps) => {
|
export const WideShuffleButton = ({ ...props }: TextPlayButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<WidePlayButton {...props}>
|
<PlayTextButton {...props}>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Icon fill="default" icon="mediaShuffle" size="lg" />
|
<Icon fill="default" icon="mediaShuffle" size="lg" />
|
||||||
{t('action.shuffle', { postProcess: 'sentenceCase' })}
|
{t('action.shuffle', { postProcess: 'sentenceCase' })}
|
||||||
</Group>
|
</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 };
|
||||||
|
};
|
||||||
@@ -3,7 +3,9 @@ import { useCallback, useRef } from 'react';
|
|||||||
interface UseLongPressOptions<T extends HTMLElement = HTMLElement> {
|
interface UseLongPressOptions<T extends HTMLElement = HTMLElement> {
|
||||||
delay?: number;
|
delay?: number;
|
||||||
onClick?: (event: React.MouseEvent<T> | React.TouchEvent<T>) => void;
|
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;
|
onLongPress?: (event: React.MouseEvent<T> | React.TouchEvent<T>) => void;
|
||||||
|
onStart?: (event: React.MouseEvent<T> | React.TouchEvent<T>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseLongPressReturn {
|
interface UseLongPressReturn {
|
||||||
@@ -18,7 +20,9 @@ interface UseLongPressReturn {
|
|||||||
export const useLongPress = <T extends HTMLElement = HTMLElement>({
|
export const useLongPress = <T extends HTMLElement = HTMLElement>({
|
||||||
delay = 500,
|
delay = 500,
|
||||||
onClick,
|
onClick,
|
||||||
|
onFinish,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
|
onStart,
|
||||||
}: UseLongPressOptions<T>): UseLongPressReturn => {
|
}: UseLongPressOptions<T>): UseLongPressReturn => {
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const targetRef = useRef<EventTarget | null>(null);
|
const targetRef = useRef<EventTarget | null>(null);
|
||||||
@@ -31,6 +35,8 @@ export const useLongPress = <T extends HTMLElement = HTMLElement>({
|
|||||||
targetRef.current = event.target;
|
targetRef.current = event.target;
|
||||||
eventRef.current = event;
|
eventRef.current = event;
|
||||||
|
|
||||||
|
onStart?.(event);
|
||||||
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
timeoutRef.current = setTimeout(() => {
|
||||||
longPressTriggeredRef.current = true;
|
longPressTriggeredRef.current = true;
|
||||||
if (eventRef.current) {
|
if (eventRef.current) {
|
||||||
@@ -38,7 +44,7 @@ export const useLongPress = <T extends HTMLElement = HTMLElement>({
|
|||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
},
|
},
|
||||||
[onLongPress, delay],
|
[onLongPress, onStart, delay],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const clear = useCallback(() => {
|
||||||
@@ -57,19 +63,23 @@ export const useLongPress = <T extends HTMLElement = HTMLElement>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
|
const event = eventRef.current;
|
||||||
clear();
|
clear();
|
||||||
if (!longPressTriggeredRef.current && onClick && eventRef.current) {
|
if (!longPressTriggeredRef.current && onClick && event) {
|
||||||
onClick(eventRef.current);
|
onClick(event);
|
||||||
}
|
}
|
||||||
|
onFinish?.(event || null);
|
||||||
longPressTriggeredRef.current = false;
|
longPressTriggeredRef.current = false;
|
||||||
eventRef.current = null;
|
eventRef.current = null;
|
||||||
}, [clear, onClick]);
|
}, [clear, onClick, onFinish]);
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
const event = eventRef.current;
|
||||||
clear();
|
clear();
|
||||||
|
onFinish?.(event || null);
|
||||||
longPressTriggeredRef.current = false;
|
longPressTriggeredRef.current = false;
|
||||||
eventRef.current = null;
|
eventRef.current = null;
|
||||||
}, [clear]);
|
}, [clear, onFinish]);
|
||||||
|
|
||||||
const handleTouchStart = useCallback(
|
const handleTouchStart = useCallback(
|
||||||
(event: React.TouchEvent) => {
|
(event: React.TouchEvent) => {
|
||||||
@@ -79,19 +89,23 @@ export const useLongPress = <T extends HTMLElement = HTMLElement>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTouchEnd = useCallback(() => {
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
const event = eventRef.current;
|
||||||
clear();
|
clear();
|
||||||
if (!longPressTriggeredRef.current && onClick && eventRef.current) {
|
if (!longPressTriggeredRef.current && onClick && event) {
|
||||||
onClick(eventRef.current);
|
onClick(event);
|
||||||
}
|
}
|
||||||
|
onFinish?.(event || null);
|
||||||
longPressTriggeredRef.current = false;
|
longPressTriggeredRef.current = false;
|
||||||
eventRef.current = null;
|
eventRef.current = null;
|
||||||
}, [clear, onClick]);
|
}, [clear, onClick, onFinish]);
|
||||||
|
|
||||||
const handleTouchCancel = useCallback(() => {
|
const handleTouchCancel = useCallback(() => {
|
||||||
|
const event = eventRef.current;
|
||||||
clear();
|
clear();
|
||||||
|
onFinish?.(event || null);
|
||||||
longPressTriggeredRef.current = false;
|
longPressTriggeredRef.current = false;
|
||||||
eventRef.current = null;
|
eventRef.current = null;
|
||||||
}, [clear]);
|
}, [clear, onFinish]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onMouseDown: handleMouseDown,
|
onMouseDown: handleMouseDown,
|
||||||
|
|||||||
Reference in New Issue
Block a user