add improved play button group on list headers

This commit is contained in:
jeffvli
2025-12-13 00:52:23 -08:00
parent eb100351a6
commit ab1176d4f6
6 changed files with 335 additions and 40 deletions
@@ -10,6 +10,7 @@
}
.play-button-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
@@ -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: <PlayButtonGroup onPlay={handlePlay} />,
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<HTMLButtonElement>(null);
return (
<div className={styles.playButtonContainer}>
<DefaultPlayButton
className={className}
loading={isPlayerFetching}
onClick={openPlayTypeModal}
onClick={() => setIsOpen((prev) => !prev)}
ref={buttonRef}
variant={variant}
{...props}
/>
<AnimatePresence>
{isOpen && (
<PlayButtonGroupPopover
loading={isPlayerFetching}
onClose={() => setIsOpen(false)}
onPlay={handlePlay}
position="bottom"
triggerRef={buttonRef}
/>
)}
</AnimatePresence>
</div>
);
};
@@ -8,3 +8,9 @@
width: 100%;
height: 100%;
}
.play-button-group-vertical {
flex-direction: column;
width: auto;
height: auto;
}
@@ -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<HTMLElement | null>;
}
interface PlayButtonGroupProps {
loading?: boolean | Play;
onPlay: (type: Play) => void;
}
type PopoverPosition = 'bottom' | 'left' | 'right' | 'top';
export const PlayButtonGroup = ({ loading, onPlay }: PlayButtonGroupProps) => {
return (
<div className={styles.playButtonGroup}>
@@ -131,3 +144,275 @@ export const PlayButtonGroup = ({ loading, onPlay }: PlayButtonGroupProps) => {
</div>
);
};
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<DOMRect | null>(null);
const itemVariants = getItemVariants(position);
const isVertical = position === 'left' || position === 'right';
const popoverRef = useRef<HTMLDivElement>(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 = (
<motion.div
animate="visible"
className={`${styles.playButtonGroup} ${isVertical ? styles.playButtonGroupVertical : ''}`}
exit="exit"
initial="hidden"
ref={popoverRef}
style={positionStyles}
variants={containerVariants}
>
<Tooltip.Group>
{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 (
<motion.div key={button.type} variants={combinedVariants}>
<Tooltip label={button.label}>
<PlayButton
fill={button.type === Play.NOW}
icon={button.icon}
isSecondary={button.secondary}
loading={loading === button.type}
onClick={() => onPlay(button.type)}
onLongPress={() =>
onPlay(LONG_PRESS_PLAY_BEHAVIOR[button.type])
}
/>
</Tooltip>
</motion.div>
);
})}
</Tooltip.Group>
</motion.div>
);
return <Portal>{content}</Portal>;
};
@@ -15,25 +15,26 @@ export interface DefaultPlayButtonProps extends ActionIconProps {
size?: number | string;
}
export const DefaultPlayButton = ({
className,
variant = 'filled',
...props
}: DefaultPlayButtonProps) => {
return (
<ActionIcon
className={clsx(styles.textButton, className, {
[styles.unthemed]: variant !== 'filled',
})}
icon="mediaPlay"
iconProps={{
size: 'xl',
}}
variant={variant}
{...props}
/>
);
};
export const DefaultPlayButton = forwardRef<HTMLButtonElement, DefaultPlayButtonProps>(
({ className, variant = 'filled', ...props }, ref) => {
return (
<ActionIcon
ref={ref}
className={clsx(styles.textButton, className, {
[styles.unthemed]: variant !== 'filled',
})}
icon="mediaPlay"
iconProps={{
size: 'xl',
}}
variant={variant}
{...props}
/>
);
},
);
DefaultPlayButton.displayName = 'DefaultPlayButton';
interface TextPlayButtonProps extends ButtonProps {}
+3
View File
@@ -0,0 +1,3 @@
import { useClickOutside as useMantineClickOutside } from '@mantine/hooks';
export const useClickOutside = useMantineClickOutside;