;
+}
+
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;