diff --git a/src/renderer/components/item-card/item-card-controls.module.css b/src/renderer/components/item-card/item-card-controls.module.css index a1834165b..9c5d0ae93 100644 --- a/src/renderer/components/item-card/item-card-controls.module.css +++ b/src/renderer/components/item-card/item-card-controls.module.css @@ -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; diff --git a/src/renderer/components/item-card/item-card-controls.tsx b/src/renderer/components/item-card/item-card-controls.tsx index 15a2ca19e..9fb507b64 100644 --- a/src/renderer/components/item-card/item-card-controls.tsx +++ b/src/renderer/components/item-card/item-card-controls.tsx @@ -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 = ({ {controls?.onPlay && ( <> - - + - prev.rating === next.rating, ); -const PlayButton = memo( - ({ - loading, - onClick, - onLongPress, - }: { - loading?: boolean; - onClick?: (e: React.MouseEvent) => void; - onLongPress?: (e: React.MouseEvent) => void; - }) => { - const isPlayerFetching = useIsPlayerFetching(); - - const disabled = isPlayerFetching || loading; - - const longPressHandlers = useLongPress({ - onClick: (e) => { - if (disabled || loading) { - return; - } - e.stopPropagation(); - e.preventDefault(); - onClick?.(e as React.MouseEvent); - }, - onLongPress: (e) => { - if (disabled || loading) { - return; - } - e.stopPropagation(); - e.preventDefault(); - onLongPress?.(e as React.MouseEvent); - }, - }); - - const handleMouseDown = (e: React.MouseEvent) => { - e.stopPropagation(); - longPressHandlers.onMouseDown?.(e); - }; - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - }; - - return ( - - ); - }, -); - -const SecondaryPlayButton = memo( - ({ - className, - icon, - onClick, - onLongPress, - }: { - className?: string; - icon: keyof typeof AppIcon; - onClick?: (e: React.MouseEvent) => void; - onLongPress?: (e: React.MouseEvent) => void; - }) => { - const isPlayerFetching = useIsPlayerFetching(); - - const disabled = isPlayerFetching; - - const longPressHandlers = useLongPress({ - onClick: (e) => { - e.stopPropagation(); - e.preventDefault(); - onClick?.(e as React.MouseEvent); - }, - onLongPress: (e) => { - e.stopPropagation(); - e.preventDefault(); - onLongPress?.(e as React.MouseEvent); - }, - }); - - const handleMouseDown = (e: React.MouseEvent) => { - e.stopPropagation(); - longPressHandlers.onMouseDown?.(e); - }; - - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - }; - - return ( - - ); - }, -); - interface SecondaryButtonProps { className?: string; disabled?: boolean; diff --git a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx index 61bac9c59..c9e8b3a22 100644 --- a/src/renderer/features/albums/routes/dummy-album-detail-route.tsx +++ b/src/renderer/features/albums/routes/dummy-album-detail-route.tsx @@ -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 = () => {
- handlePlay()} /> + handlePlay()} /> {
- handlePlay(playButtonBehavior)} /> diff --git a/src/renderer/features/shared/components/library-header-bar.tsx b/src/renderer/features/shared/components/library-header-bar.tsx index fd5271db6..fff63ddba 100644 --- a/src/renderer/features/shared/components/library-header-bar.tsx +++ b/src/renderer/features/shared/components/library-header-bar.tsx @@ -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 (
- - {onPlay && } + {onPlay && } {onShuffle && } diff --git a/src/renderer/features/shared/components/play-button.module.css b/src/renderer/features/shared/components/play-button.module.css index 4b8112a5d..4b768c385 100644 --- a/src/renderer/features/shared/components/play-button.module.css +++ b/src/renderer/features/shared/components/play-button.module.css @@ -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); +} diff --git a/src/renderer/features/shared/components/play-button.tsx b/src/renderer/features/shared/components/play-button.tsx index 17e6a24a5..34a01319a 100644 --- a/src/renderer/features/shared/components/play-button.tsx +++ b/src/renderer/features/shared/components/play-button.tsx @@ -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 ( { +}: TextPlayButtonProps) => { return ( + ); + }, +); + +PlayButton.displayName = 'PlayButton'; diff --git a/src/renderer/features/shared/hooks/use-play-button-click.ts b/src/renderer/features/shared/hooks/use-play-button-click.ts new file mode 100644 index 000000000..82ce06cf7 --- /dev/null +++ b/src/renderer/features/shared/hooks/use-play-button-click.ts @@ -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) => void; + onLongPress?: (e: React.MouseEvent) => void; +} + +interface UsePlayButtonClickReturn { + handlers: { + onClick: (e: React.MouseEvent) => void; + onMouseDown: (e: React.MouseEvent) => 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({ + onClick: (e) => { + if (isDisabled || loading) { + return; + } + + e.stopPropagation(); + e.preventDefault(); + onClick?.(e as React.MouseEvent); + }, + onFinish: () => { + setIsPressing(false); + setIsLongPressing(false); + }, + onLongPress: (e) => { + if (isDisabled || loading) { + return; + } + + e.stopPropagation(); + e.preventDefault(); + setIsPressing(false); + setIsLongPressing(true); + onLongPress?.(e as React.MouseEvent); + }, + onStart: () => { + if (!isDisabled && !loading) { + setIsPressing(true); + setIsLongPressing(false); + } + }, + }); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + longPressHandlers.onMouseDown?.(e); + }, + [longPressHandlers], + ); + + const handleClick = useCallback((e: React.MouseEvent) => { + 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 }; +}; diff --git a/src/shared/hooks/use-long-press.ts b/src/shared/hooks/use-long-press.ts index e23e8c2a8..2e0ddc2a4 100644 --- a/src/shared/hooks/use-long-press.ts +++ b/src/shared/hooks/use-long-press.ts @@ -3,7 +3,9 @@ import { useCallback, useRef } from 'react'; interface UseLongPressOptions { delay?: number; onClick?: (event: React.MouseEvent | React.TouchEvent) => void; + onFinish?: (event: null | React.MouseEvent | React.TouchEvent) => void; onLongPress?: (event: React.MouseEvent | React.TouchEvent) => void; + onStart?: (event: React.MouseEvent | React.TouchEvent) => void; } interface UseLongPressReturn { @@ -18,7 +20,9 @@ interface UseLongPressReturn { export const useLongPress = ({ delay = 500, onClick, + onFinish, onLongPress, + onStart, }: UseLongPressOptions): UseLongPressReturn => { const timeoutRef = useRef(null); const targetRef = useRef(null); @@ -31,6 +35,8 @@ export const useLongPress = ({ 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 = ({ } }, delay); }, - [onLongPress, delay], + [onLongPress, onStart, delay], ); const clear = useCallback(() => { @@ -57,19 +63,23 @@ export const useLongPress = ({ ); 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 = ({ ); 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,