feat: videocard mobile action optimize
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
import { X } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
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; // 播放源名称
|
||||
currentEpisode?: number; // 当前集数
|
||||
totalEpisodes?: number; // 总集数
|
||||
}
|
||||
|
||||
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
actions,
|
||||
poster,
|
||||
sources,
|
||||
isAggregate,
|
||||
sourceName,
|
||||
currentEpisode,
|
||||
totalEpisodes,
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// 控制动画状态
|
||||
useEffect(() => {
|
||||
let animationId: number;
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
// 使用双重 requestAnimationFrame 确保DOM完全渲染
|
||||
animationId = requestAnimationFrame(() => {
|
||||
animationId = requestAnimationFrame(() => {
|
||||
setIsAnimating(true);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
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]);
|
||||
|
||||
// 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) 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';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
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">
|
||||
<Image
|
||||
src={poster}
|
||||
alt={title}
|
||||
fill
|
||||
className="object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{title}
|
||||
</h3>
|
||||
{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">
|
||||
{sourceName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
选择操作
|
||||
</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">
|
||||
{(() => {
|
||||
// 优先显示的播放源
|
||||
const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+'];
|
||||
const sortedSources = sources.sort((a, b) => {
|
||||
const aIndex = prioritySources.indexOf(a);
|
||||
const bIndex = prioritySources.indexOf(b);
|
||||
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||
if (aIndex !== -1) return -1;
|
||||
if (bIndex !== -1) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return sortedSources.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>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileActionSheet;
|
||||
Reference in New Issue
Block a user