diff --git a/src/renderer/features/shared/components/library-header-bar.module.css b/src/renderer/features/shared/components/library-header-bar.module.css index 57cc61c8a..7d7634f20 100644 --- a/src/renderer/features/shared/components/library-header-bar.module.css +++ b/src/renderer/features/shared/components/library-header-bar.module.css @@ -10,6 +10,7 @@ } .play-button-container { + position: relative; display: flex; align-items: center; justify-content: center; diff --git a/src/renderer/features/shared/components/library-header-bar.tsx b/src/renderer/features/shared/components/library-header-bar.tsx index 6902b242c..a73544562 100644 --- a/src/renderer/features/shared/components/library-header-bar.tsx +++ b/src/renderer/features/shared/components/library-header-bar.tsx @@ -1,11 +1,12 @@ -import { closeAllModals, openModal } from '@mantine/modals'; -import { CSSProperties, memo, ReactNode, useCallback } from 'react'; +import { closeAllModals } from '@mantine/modals'; +import { AnimatePresence } from 'motion/react'; +import { CSSProperties, memo, ReactNode, useCallback, useRef, useState } from 'react'; import styles from './library-header-bar.module.css'; import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context'; import { DefaultPlayButton } from '/@/renderer/features/shared/components/play-button'; -import { PlayButtonGroup } from '/@/renderer/features/shared/components/play-button-group'; +import { PlayButtonGroupPopover } from '/@/renderer/features/shared/components/play-button-group'; import { useCurrentServerId } from '/@/renderer/store'; import { Badge, BadgeProps } from '/@/shared/components/badge/badge'; import { Spinner } from '/@/shared/components/spinner/spinner'; @@ -69,34 +70,32 @@ const HeaderPlayButton = ({ [listQuery, ids, songs, player, serverId, itemType], ); - const openPlayTypeModal = useCallback(() => { - if (!serverId) return; - - openModal({ - children: , - size: 'xs', - styles: { - body: { - padding: 'var(--theme-spacing-md)', - }, - header: { - display: 'none', - }, - }, - }); - }, [serverId, handlePlay]); - const isPlayerFetching = useIsPlayerFetching(); + const [isOpen, setIsOpen] = useState(false); + const buttonRef = useRef(null); + return (
setIsOpen((prev) => !prev)} + ref={buttonRef} variant={variant} {...props} /> + + {isOpen && ( + setIsOpen(false)} + onPlay={handlePlay} + position="bottom" + triggerRef={buttonRef} + /> + )} +
); }; diff --git a/src/renderer/features/shared/components/play-button-group.module.css b/src/renderer/features/shared/components/play-button-group.module.css index cea58415b..e295218b9 100644 --- a/src/renderer/features/shared/components/play-button-group.module.css +++ b/src/renderer/features/shared/components/play-button-group.module.css @@ -8,3 +8,9 @@ width: 100%; height: 100%; } + +.play-button-group-vertical { + flex-direction: column; + width: auto; + height: auto; +} diff --git a/src/renderer/features/shared/components/play-button-group.tsx b/src/renderer/features/shared/components/play-button-group.tsx index 1a4d2c3fb..0a2bd7ba1 100644 --- a/src/renderer/features/shared/components/play-button-group.tsx +++ b/src/renderer/features/shared/components/play-button-group.tsx @@ -1,11 +1,16 @@ +import { motion } from 'motion/react'; +import { useEffect, useRef, useState } from 'react'; + import styles from './play-button-group.module.css'; import i18n from '/@/i18n/i18n'; import { PlayButton } from '/@/renderer/features/shared/components/play-button'; import { AppIconSelection } from '/@/shared/components/icon/icon'; +import { Portal } from '/@/shared/components/portal/portal'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; import { Tooltip } from '/@/shared/components/tooltip/tooltip'; +import { useClickOutside } from '/@/shared/hooks/use-click-outside'; import { Play } from '/@/shared/types/types'; const playButtons: { @@ -106,11 +111,19 @@ export const PlayTooltip = ({ ); }; +interface PlayButtonGroupPopoverProps extends PlayButtonGroupProps { + onClose?: () => void; + position?: 'bottom' | 'left' | 'right' | 'top'; + triggerRef?: React.RefObject; +} + interface PlayButtonGroupProps { loading?: boolean | Play; onPlay: (type: Play) => void; } +type PopoverPosition = 'bottom' | 'left' | 'right' | 'top'; + export const PlayButtonGroup = ({ loading, onPlay }: PlayButtonGroupProps) => { return (
@@ -131,3 +144,275 @@ export const PlayButtonGroup = ({ loading, onPlay }: PlayButtonGroupProps) => {
); }; + +const containerVariants = { + exit: { + opacity: 0, + }, + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + delayChildren: 0.1, + staggerChildren: 0.1, + }, + }, +}; + +const getItemVariants = (position: PopoverPosition) => { + const baseTransition = { + damping: 24, + stiffness: 300, + type: 'spring' as const, + }; + + switch (position) { + case 'bottom': + return { + exit: { + opacity: 0, + scale: 0.8, + transition: { + duration: 0.2, + }, + y: -10, + }, + hidden: { opacity: 0, scale: 0.8, y: -10 }, + visible: { + opacity: 1, + scale: 1, + transition: baseTransition, + y: 0, + }, + }; + case 'left': + return { + exit: { + opacity: 0, + scale: 0.8, + transition: { + duration: 0.2, + }, + x: 10, + }, + hidden: { opacity: 0, scale: 0.8, x: 10 }, + visible: { + opacity: 1, + scale: 1, + transition: baseTransition, + x: 0, + }, + }; + case 'right': + return { + exit: { + opacity: 0, + scale: 0.8, + transition: { + duration: 0.2, + }, + x: -10, + }, + hidden: { opacity: 0, scale: 0.8, x: -10 }, + visible: { + opacity: 1, + scale: 1, + transition: baseTransition, + x: 0, + }, + }; + case 'top': + return { + exit: { + opacity: 0, + scale: 0.8, + transition: { + duration: 0.2, + }, + y: 10, + }, + hidden: { opacity: 0, scale: 0.8, y: 10 }, + visible: { + opacity: 1, + scale: 1, + transition: baseTransition, + y: 0, + }, + }; + } +}; + +const getPositionStyles = ( + position: PopoverPosition, + triggerRect: DOMRect | null, +): React.CSSProperties => { + if (!triggerRect) { + return { display: 'none' }; + } + + const gap = 8; + + switch (position) { + case 'bottom': + return { + height: '64px', + left: triggerRect.left + triggerRect.width / 2, + position: 'fixed' as const, + top: triggerRect.bottom + gap, + transform: 'translateX(-50%)', + zIndex: 1000, + }; + case 'left': + return { + height: '64px', + left: triggerRect.left - gap, + position: 'fixed' as const, + top: triggerRect.top + triggerRect.height / 2, + transform: 'translate(-100%, -50%)', + zIndex: 1000, + }; + case 'right': + return { + height: '64px', + left: triggerRect.right + gap, + position: 'fixed' as const, + top: triggerRect.top + triggerRect.height / 2, + transform: 'translateY(-50%)', + zIndex: 1000, + }; + case 'top': + return { + height: '64px', + left: triggerRect.left + triggerRect.width / 2, + position: 'fixed' as const, + top: triggerRect.top - gap, + transform: 'translate(-50%, -100%)', + zIndex: 1000, + }; + } +}; + +const getArchOffset = (index: number, position: PopoverPosition): { x?: number; y?: number } => { + const archCurve = 16; + const isVertical = position === 'left' || position === 'right'; + const isMiddle = index === 1; + + if (isMiddle) { + return {}; + } + + if (isVertical) { + // For left/right positions, offset horizontally toward the parent + if (position === 'right') { + return { x: -archCurve }; + } else { + return { x: archCurve }; + } + } else { + // For top/bottom positions, offset vertically toward the parent + if (position === 'bottom') { + return { y: -archCurve }; + } else { + return { y: archCurve }; + } + } +}; + +export const PlayButtonGroupPopover = ({ + loading, + onClose, + onPlay, + position = 'bottom', + triggerRef, +}: PlayButtonGroupPopoverProps) => { + const [triggerRect, setTriggerRect] = useState(null); + const itemVariants = getItemVariants(position); + const isVertical = position === 'left' || position === 'right'; + const popoverRef = useRef(null); + + useClickOutside( + () => { + onClose?.(); + }, + ['click', 'touchstart'], + [popoverRef, triggerRef].map((ref) => ref?.current).filter(Boolean) as HTMLElement[], + ); + + useEffect(() => { + if (!triggerRef?.current) return; + + const updatePosition = () => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setTriggerRect(rect); + } + }; + + requestAnimationFrame(updatePosition); + + window.addEventListener('scroll', updatePosition, true); + window.addEventListener('resize', updatePosition); + + return () => { + window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener('resize', updatePosition); + }; + }, [triggerRef]); + + const positionStyles = getPositionStyles(position, triggerRect); + + const content = ( + + + {playButtons.map((button, index) => { + const archOffset = getArchOffset(index, position); + const combinedVariants = { + ...itemVariants, + exit: { + ...itemVariants.exit, + x: (itemVariants.exit.x ?? 0) + (archOffset.x ?? 0), + y: (itemVariants.exit.y ?? 0) + (archOffset.y ?? 0), + }, + hidden: { + ...itemVariants.hidden, + x: (itemVariants.hidden.x ?? 0) + (archOffset.x ?? 0), + y: (itemVariants.hidden.y ?? 0) + (archOffset.y ?? 0), + }, + visible: { + ...itemVariants.visible, + x: (itemVariants.visible.x ?? 0) + (archOffset.x ?? 0), + y: (itemVariants.visible.y ?? 0) + (archOffset.y ?? 0), + }, + }; + + return ( + + + onPlay(button.type)} + onLongPress={() => + onPlay(LONG_PRESS_PLAY_BEHAVIOR[button.type]) + } + /> + + + ); + })} + + + ); + + return {content}; +}; diff --git a/src/renderer/features/shared/components/play-button.tsx b/src/renderer/features/shared/components/play-button.tsx index 41ad255b9..28e0c1d95 100644 --- a/src/renderer/features/shared/components/play-button.tsx +++ b/src/renderer/features/shared/components/play-button.tsx @@ -15,25 +15,26 @@ export interface DefaultPlayButtonProps extends ActionIconProps { size?: number | string; } -export const DefaultPlayButton = ({ - className, - variant = 'filled', - ...props -}: DefaultPlayButtonProps) => { - return ( - - ); -}; +export const DefaultPlayButton = forwardRef( + ({ className, variant = 'filled', ...props }, ref) => { + return ( + + ); + }, +); + +DefaultPlayButton.displayName = 'DefaultPlayButton'; interface TextPlayButtonProps extends ButtonProps {} diff --git a/src/shared/hooks/use-click-outside.ts b/src/shared/hooks/use-click-outside.ts new file mode 100644 index 000000000..bb704cd9a --- /dev/null +++ b/src/shared/hooks/use-click-outside.ts @@ -0,0 +1,3 @@ +import { useClickOutside as useMantineClickOutside } from '@mantine/hooks'; + +export const useClickOutside = useMantineClickOutside;