mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-14 12:30:06 +02:00
extract play button from item card and add long press animation
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user