Files
MoonTVPlus/src/components/MobileActionSheet.tsx
T

438 lines
15 KiB
TypeScript

import { Radio, X } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface ActionItem {
id: string;
label: string;
icon: React.ReactNode;
onClick: (e?: React.MouseEvent) => void | Promise<void>;
color?: 'default' | 'danger' | 'primary';
disabled?: boolean;
}
interface MobileActionSheetProps {
isOpen: boolean;
onClose: () => void;
title: string;
actions: ActionItem[];
poster?: string;
sources?: string[]; // 播放源信息
isAggregate?: boolean; // 是否为聚合内容
sourceName?: string; // 播放源名称
directLinkUrl?: string; // 直链播放完整链接
currentEpisode?: number; // 当前集数
totalEpisodes?: number; // 总集数
origin?: 'vod' | 'live';
onPosterClick?: () => void; // 海报点击回调
description?: string; // 标题下方描述文案
}
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
isOpen,
onClose,
title,
actions,
poster,
sources,
isAggregate,
sourceName,
directLinkUrl,
currentEpisode,
totalEpisodes,
origin = 'vod',
onPosterClick,
description = '选择操作',
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [mounted, setMounted] = useState(false);
const [isTitleOverflowing, setIsTitleOverflowing] = useState(false);
const backdropPressStarted = useRef(false);
const titleRef = useRef<HTMLHeadingElement>(null);
// 确保组件在客户端挂载后才渲染 Portal
useEffect(() => {
setMounted(true);
}, []);
// 控制动画状态
useEffect(() => {
let animationId: number;
let timer: NodeJS.Timeout;
if (isOpen) {
backdropPressStarted.current = false;
setIsVisible(true);
// 使用双重 requestAnimationFrame 确保DOM完全渲染
animationId = requestAnimationFrame(() => {
animationId = requestAnimationFrame(() => {
setIsAnimating(true);
});
});
} else {
backdropPressStarted.current = false;
setIsAnimating(false);
// 等待动画完成后隐藏组件
timer = setTimeout(() => {
setIsVisible(false);
}, 200);
}
return () => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (timer) {
clearTimeout(timer);
}
};
}, [isOpen]);
// 阻止背景滚动
useEffect(() => {
if (isVisible) {
// 保存当前滚动位置
const scrollY = window.scrollY;
const scrollX = window.scrollX;
const body = document.body;
const html = document.documentElement;
// 获取滚动条宽度
const scrollBarWidth = window.innerWidth - html.clientWidth;
// 保存原始样式
const originalBodyStyle = {
position: body.style.position,
top: body.style.top,
left: body.style.left,
right: body.style.right,
width: body.style.width,
paddingRight: body.style.paddingRight,
overflow: body.style.overflow,
};
// 设置body样式来阻止滚动,但保持原位置
body.style.position = 'fixed';
body.style.top = `-${scrollY}px`;
body.style.left = `-${scrollX}px`;
body.style.right = '0';
body.style.width = '100%';
body.style.overflow = 'hidden';
body.style.paddingRight = `${scrollBarWidth}px`;
return () => {
// 恢复所有原始样式
body.style.position = originalBodyStyle.position;
body.style.top = originalBodyStyle.top;
body.style.left = originalBodyStyle.left;
body.style.right = originalBodyStyle.right;
body.style.width = originalBodyStyle.width;
body.style.paddingRight = originalBodyStyle.paddingRight;
body.style.overflow = originalBodyStyle.overflow;
// 使用 requestAnimationFrame 确保样式恢复后再滚动
requestAnimationFrame(() => {
window.scrollTo(scrollX, scrollY);
});
};
}
}, [isVisible]);
useEffect(() => {
const element = titleRef.current;
if (!element || !isVisible) {
setIsTitleOverflowing(false);
return;
}
const checkOverflow = () => {
setIsTitleOverflowing(element.scrollWidth > element.clientWidth + 1);
};
checkOverflow();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', checkOverflow);
return () => window.removeEventListener('resize', checkOverflow);
}
const observer = new ResizeObserver(checkOverflow);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [isVisible, title]);
// ESC键关闭
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isVisible) {
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}
}, [isVisible, onClose]);
if (!isVisible || !mounted) return null;
const getActionColor = (color: ActionItem['color']) => {
switch (color) {
case 'danger':
return 'text-red-600 dark:text-red-400';
case 'primary':
return 'text-green-600 dark:text-green-400';
default:
return 'text-gray-700 dark:text-gray-300';
}
};
const getActionHoverColor = (color: ActionItem['color']) => {
switch (color) {
case 'danger':
return 'hover:bg-red-50/50 dark:hover:bg-red-900/10';
case 'primary':
return 'hover:bg-green-50/50 dark:hover:bg-green-900/10';
default:
return 'hover:bg-gray-50/50 dark:hover:bg-gray-800/20';
}
};
const armBackdropClose = () => {
backdropPressStarted.current = true;
};
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
// 菜单打开前那次长按的松手会产生一个“悬空 click”,
// 这次 click 并不是从遮罩开始按下的,所以不能拿来关闭菜单。
if (!backdropPressStarted.current) {
e.preventDefault();
e.stopPropagation();
return;
}
backdropPressStarted.current = false;
onClose();
};
const content = (
<div
className="fixed inset-0 z-[9999] flex items-end justify-center"
onTouchMove={(e) => {
// 阻止最外层容器的触摸移动,防止背景滚动
e.preventDefault();
e.stopPropagation();
}}
style={{
touchAction: 'none', // 禁用所有触摸操作
}}
>
{/* 背景遮罩 */}
<div
className={`absolute inset-0 bg-black/50 transition-opacity duration-200 ease-out ${isAnimating ? 'opacity-100' : 'opacity-0'
}`}
onPointerDown={armBackdropClose}
onTouchStart={armBackdropClose}
onClick={handleBackdropClick}
onTouchMove={(e) => {
// 只阻止滚动,允许其他触摸事件(包括点击)
e.preventDefault();
}}
onWheel={(e) => {
// 阻止滚轮滚动
e.preventDefault();
}}
style={{
backdropFilter: 'blur(4px)',
willChange: 'opacity',
touchAction: 'none', // 禁用所有触摸操作
}}
/>
{/* 操作表单 */}
<div
className="relative w-full max-w-lg mx-4 mb-4 bg-white dark:bg-gray-900 rounded-2xl shadow-2xl transition-all duration-200 ease-out"
onTouchMove={(e) => {
// 允许操作表单内部滚动,阻止事件冒泡到外层
e.stopPropagation();
}}
style={{
marginBottom: 'calc(1rem + env(safe-area-inset-bottom))',
willChange: 'transform, opacity',
backfaceVisibility: 'hidden', // 避免闪烁
transform: isAnimating
? 'translateY(0) translateZ(0)'
: 'translateY(100%) translateZ(0)', // 组合变换保持滑入效果和硬件加速
opacity: isAnimating ? 1 : 0,
touchAction: 'auto', // 允许操作表单内的正常触摸操作
}}
>
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-3 flex-1 min-w-0">
{poster && (
<div
className="relative w-12 h-16 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0 cursor-pointer hover:opacity-90 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onPosterClick?.();
}}
>
<Image
src={poster}
alt={title}
fill
className={origin === 'live' ? 'object-contain' : 'object-cover'}
loading="lazy"
/>
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 min-w-0">
<div className="relative min-w-0 flex-1 group/title">
<h3
ref={titleRef}
className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate"
>
{title}
</h3>
{isTitleOverflowing && (
<div className="absolute bottom-full left-1/2 z-10 mb-2 w-max max-w-[min(20rem,calc(100vw-2rem))] -translate-x-1/2 rounded-lg bg-gray-800 px-3 py-2 text-center text-sm text-white shadow-xl opacity-0 invisible transition-all duration-200 ease-out whitespace-normal break-words pointer-events-none group-hover/title:opacity-100 group-hover/title:visible dark:bg-gray-900">
{title}
<div className="absolute top-full left-1/2 h-0 w-0 -translate-x-1/2 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800 dark:border-t-gray-900"></div>
</div>
)}
</div>
{sourceName && (
<span className="flex-shrink-0 text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
{origin === 'live' && (
<Radio size={12} className="inline-block text-gray-500 dark:text-gray-400 mr-1.5" />
)}
{sourceName}
</span>
)}
</div>
{directLinkUrl && (
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400 break-all">
{directLinkUrl}
</p>
)}
<p className="text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* 操作列表 */}
<div className="px-4 py-2">
{actions.map((action, index) => (
<div key={action.id}>
<button
onClick={() => {
action.onClick();
onClose();
}}
disabled={action.disabled}
className={`
w-full flex items-center gap-4 py-4 px-2 transition-all duration-150 ease-out
${action.disabled
? 'opacity-50 cursor-not-allowed'
: `${getActionHoverColor(action.color)} active:scale-[0.98]`
}
`}
style={{ willChange: 'transform, background-color' }}
>
{/* 图标 - 使用线条风格 */}
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0">
<span className={`transition-colors duration-150 ${action.disabled
? 'text-gray-400 dark:text-gray-600'
: getActionColor(action.color)
}`}>
{action.icon}
</span>
</div>
{/* 文字 */}
<span className={`
text-left font-medium text-base flex-1
${action.disabled
? 'text-gray-400 dark:text-gray-600'
: 'text-gray-900 dark:text-gray-100'
}
`}>
{action.label}
</span>
{/* 播放进度 - 只在播放按钮且有播放记录时显示 */}
{action.id === 'play' && currentEpisode && totalEpisodes && (
<span className="text-sm text-gray-500 dark:text-gray-400 font-medium">
{currentEpisode}/{totalEpisodes}
</span>
)}
</button>
{/* 分割线 - 最后一项不显示 */}
{index < actions.length - 1 && (
<div className="border-b border-gray-100 dark:border-gray-800 ml-10"></div>
)}
</div>
))}
</div>
{/* 播放源信息展示区域 */}
{isAggregate && sources && sources.length > 0 && (
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800">
{/* 标题区域 */}
<div className="mb-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
可用播放源
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{sources.length} 个播放源
</p>
</div>
{/* 播放源列表 */}
<div className="max-h-32 overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
{sources.map((source, index) => (
<div
key={index}
className="flex items-center gap-2 py-2 px-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800/30"
>
<div className="w-1 h-1 bg-gray-400 dark:bg-gray-500 rounded-full flex-shrink-0" />
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
{source}
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
);
// 使用 Portal 将组件渲染到 document.body
return createPortal(content, document.body);
};
export default MobileActionSheet;