mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-15 16:04:19 +02:00
add improved play button group on list headers
This commit is contained in:
@@ -10,6 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.play-button-container {
|
.play-button-container {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { closeAllModals, openModal } from '@mantine/modals';
|
import { closeAllModals } from '@mantine/modals';
|
||||||
import { CSSProperties, memo, ReactNode, useCallback } from 'react';
|
import { AnimatePresence } from 'motion/react';
|
||||||
|
import { CSSProperties, memo, ReactNode, useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
import styles from './library-header-bar.module.css';
|
import styles from './library-header-bar.module.css';
|
||||||
|
|
||||||
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
|
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { DefaultPlayButton } 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 { PlayButtonGroupPopover } 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';
|
||||||
import { Spinner } from '/@/shared/components/spinner/spinner';
|
import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||||
@@ -69,34 +70,32 @@ const HeaderPlayButton = ({
|
|||||||
[listQuery, ids, songs, player, serverId, itemType],
|
[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 isPlayerFetching = useIsPlayerFetching();
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.playButtonContainer}>
|
<div className={styles.playButtonContainer}>
|
||||||
<DefaultPlayButton
|
<DefaultPlayButton
|
||||||
className={className}
|
className={className}
|
||||||
loading={isPlayerFetching}
|
loading={isPlayerFetching}
|
||||||
onClick={openPlayTypeModal}
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
ref={buttonRef}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<PlayButtonGroupPopover
|
||||||
|
loading={isPlayerFetching}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
position="bottom"
|
||||||
|
triggerRef={buttonRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,3 +8,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 styles from './play-button-group.module.css';
|
||||||
|
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
import { PlayButton } from '/@/renderer/features/shared/components/play-button';
|
||||||
import { AppIconSelection } from '/@/shared/components/icon/icon';
|
import { AppIconSelection } from '/@/shared/components/icon/icon';
|
||||||
|
import { Portal } from '/@/shared/components/portal/portal';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
|
||||||
|
import { useClickOutside } from '/@/shared/hooks/use-click-outside';
|
||||||
import { Play } from '/@/shared/types/types';
|
import { Play } from '/@/shared/types/types';
|
||||||
|
|
||||||
const playButtons: {
|
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 {
|
interface PlayButtonGroupProps {
|
||||||
loading?: boolean | Play;
|
loading?: boolean | Play;
|
||||||
onPlay: (type: Play) => void;
|
onPlay: (type: Play) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PopoverPosition = 'bottom' | 'left' | 'right' | 'top';
|
||||||
|
|
||||||
export const PlayButtonGroup = ({ loading, onPlay }: PlayButtonGroupProps) => {
|
export const PlayButtonGroup = ({ loading, onPlay }: PlayButtonGroupProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.playButtonGroup}>
|
<div className={styles.playButtonGroup}>
|
||||||
@@ -131,3 +144,275 @@ export const PlayButtonGroup = ({ loading, onPlay }: PlayButtonGroupProps) => {
|
|||||||
</div>
|
</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;
|
size?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DefaultPlayButton = ({
|
export const DefaultPlayButton = forwardRef<HTMLButtonElement, DefaultPlayButtonProps>(
|
||||||
className,
|
({ className, variant = 'filled', ...props }, ref) => {
|
||||||
variant = 'filled',
|
return (
|
||||||
...props
|
<ActionIcon
|
||||||
}: DefaultPlayButtonProps) => {
|
ref={ref}
|
||||||
return (
|
className={clsx(styles.textButton, className, {
|
||||||
<ActionIcon
|
[styles.unthemed]: variant !== 'filled',
|
||||||
className={clsx(styles.textButton, className, {
|
})}
|
||||||
[styles.unthemed]: variant !== 'filled',
|
icon="mediaPlay"
|
||||||
})}
|
iconProps={{
|
||||||
icon="mediaPlay"
|
size: 'xl',
|
||||||
iconProps={{
|
}}
|
||||||
size: 'xl',
|
variant={variant}
|
||||||
}}
|
{...props}
|
||||||
variant={variant}
|
/>
|
||||||
{...props}
|
);
|
||||||
/>
|
},
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
DefaultPlayButton.displayName = 'DefaultPlayButton';
|
||||||
|
|
||||||
interface TextPlayButtonProps extends ButtonProps {}
|
interface TextPlayButtonProps extends ButtonProps {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { useClickOutside as useMantineClickOutside } from '@mantine/hooks';
|
||||||
|
|
||||||
|
export const useClickOutside = useMantineClickOutside;
|
||||||
Reference in New Issue
Block a user