集数长按或右键显示原名

This commit is contained in:
mtvpls
2026-07-23 15:06:12 +08:00
parent b21e7d9549
commit 5d90eb8ce7
+218 -45
View File
@@ -21,6 +21,122 @@ import { getVideoResolutionFromM3u8 } from '@/lib/utils';
import DanmakuPanel from '@/components/DanmakuPanel';
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
import ProxyImage from '@/components/ProxyImage';
import { useLongPress } from '@/hooks/useLongPress';
/** 选集按钮上显示的短标签(数字等) */
function getEpisodeDisplayLabel(
title: string | undefined,
episodeNumber: number
): string {
if (!title) {
return String(episodeNumber);
}
// 如果是 OVA 格式,直接返回完整标题
if (title.match(/^OVA\s+\d+/i)) {
return title;
}
// 如果匹配 S01E01 格式,只显示集数部分(去掉 SxxE)
const sxxexxMatch = title.match(/[Ss]\d+[Ee](\d{1,4}(?:\.\d+)?)/);
if (sxxexxMatch) {
return sxxexxMatch[1];
}
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数)
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
if (match) {
return match[1];
}
return title;
}
interface EpisodeNamePopupState {
title: string;
x: number;
y: number;
placement: 'top' | 'bottom';
}
interface EpisodeButtonProps {
episodeNumber: number;
isActive: boolean;
isWatched: boolean;
originalTitle?: string;
inactiveEpisodeClass: string;
onSelect: (zeroBasedIndex: number) => void;
onShowOriginalName: (title: string, rect: DOMRect) => void;
}
/** 单集按钮:点击选集;移动端长按 / 桌面右键显示原集名 popup */
const EpisodeButton: React.FC<EpisodeButtonProps> = ({
episodeNumber,
isActive,
isWatched,
originalTitle,
inactiveEpisodeClass,
onSelect,
onShowOriginalName,
}) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const displayLabel = getEpisodeDisplayLabel(originalTitle, episodeNumber);
const canShowOriginalName =
!!originalTitle && originalTitle.trim() !== '' && originalTitle !== displayLabel;
const showOriginalName = useCallback(() => {
if (!canShowOriginalName || !buttonRef.current) return;
onShowOriginalName(originalTitle!, buttonRef.current.getBoundingClientRect());
}, [canShowOriginalName, onShowOriginalName, originalTitle]);
const longPressProps = useLongPress({
onLongPress: showOriginalName,
onClick: () => {
if (!isActive) {
onSelect(episodeNumber - 1);
}
},
longPressDelay: 500,
});
return (
<button
ref={buttonRef}
type='button'
// 不用 disabled,否则当前集无法长按/右键查看原名
aria-disabled={isActive || undefined}
aria-current={isActive ? 'true' : undefined}
onClick={() => {
if (!isActive) {
onSelect(episodeNumber - 1);
}
}}
onContextMenu={(e) => {
if (!canShowOriginalName) return;
e.preventDefault();
e.stopPropagation();
showOriginalName();
}}
{...longPressProps}
className={`relative h-10 min-w-10 px-3 py-2 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 whitespace-nowrap font-mono border select-none
${isActive
? 'bg-green-500 text-white border-green-400 shadow-lg shadow-green-500/25 dark:bg-green-600 cursor-default'
: isWatched
? 'bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100 hover:scale-105 dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-700/60 dark:hover:bg-emerald-900/30'
: inactiveEpisodeClass
}`.trim()}
style={
{
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
} as React.CSSProperties
}
title={isWatched && !isActive ? '已观看过' : undefined}
>
{isWatched && !isActive && (
<span className='absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400' />
)}
{displayLabel}
</button>
);
};
// 定义视频信息类型
interface VideoInfo {
@@ -207,6 +323,71 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
// 标记是否正在进行初始测速
const [isInitialTesting, setIsInitialTesting] = useState(false);
const [watchedEpisodes, setWatchedEpisodes] = useState<Set<number>>(new Set());
// 选集按钮长按/右键:原集名 popup(样式参考标题上方 aka 提示)
const [episodeNamePopup, setEpisodeNamePopup] =
useState<EpisodeNamePopupState | null>(null);
const episodeNamePopupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
const clearEpisodeNamePopupTimer = useCallback(() => {
if (episodeNamePopupTimerRef.current) {
clearTimeout(episodeNamePopupTimerRef.current);
episodeNamePopupTimerRef.current = null;
}
}, []);
const hideEpisodeNamePopup = useCallback(() => {
clearEpisodeNamePopupTimer();
setEpisodeNamePopup(null);
}, [clearEpisodeNamePopupTimer]);
const showEpisodeNamePopup = useCallback(
(title: string, rect: DOMRect) => {
const gap = 8;
const estimatedHeight = 40;
const spaceAbove = rect.top;
const placement: 'top' | 'bottom' =
spaceAbove < estimatedHeight + gap ? 'bottom' : 'top';
const x = rect.left + rect.width / 2;
const y =
placement === 'top' ? rect.top - gap : rect.bottom + gap;
clearEpisodeNamePopupTimer();
setEpisodeNamePopup({ title, x, y, placement });
// 自动消失,避免遮挡后续操作
episodeNamePopupTimerRef.current = setTimeout(() => {
setEpisodeNamePopup(null);
episodeNamePopupTimerRef.current = null;
}, 2500);
},
[clearEpisodeNamePopupTimer]
);
useEffect(() => {
if (!episodeNamePopup) return;
const handleDismiss = () => hideEpisodeNamePopup();
// 下一帧再绑定,避免触发本次的右键/触摸立即关闭
const bindId = window.setTimeout(() => {
window.addEventListener('scroll', handleDismiss, true);
window.addEventListener('touchstart', handleDismiss, { passive: true });
window.addEventListener('mousedown', handleDismiss);
window.addEventListener('keydown', handleDismiss);
}, 0);
return () => {
window.clearTimeout(bindId);
window.removeEventListener('scroll', handleDismiss, true);
window.removeEventListener('touchstart', handleDismiss);
window.removeEventListener('mousedown', handleDismiss);
window.removeEventListener('keydown', handleDismiss);
};
}, [episodeNamePopup, hideEpisodeNamePopup]);
useEffect(() => {
return () => clearEpisodeNamePopupTimer();
}, [clearEpisodeNamePopupTimer]);
// 使用 ref 来避免闭包问题
const attemptedSourcesRef = useRef<Set<string>>(new Set());
@@ -842,51 +1023,18 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
// 过滤掉被屏蔽的集数,但保持原有索引
return episodes
.filter(episodeNumber => !isEpisodeFiltered(episodeNumber))
.map((episodeNumber) => {
const isActive = episodeNumber === value;
const isWatched = watchedEpisodes.has(episodeNumber);
return (
<button
key={episodeNumber}
disabled={isActive}
onClick={() => handleEpisodeClick(episodeNumber - 1)}
className={`relative h-10 min-w-10 px-3 py-2 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 whitespace-nowrap font-mono border
${isActive
? 'bg-green-500 text-white border-green-400 shadow-lg shadow-green-500/25 dark:bg-green-600'
: isWatched
? 'bg-emerald-50 text-emerald-700 border-emerald-200 hover:bg-emerald-100 hover:scale-105 dark:bg-emerald-900/20 dark:text-emerald-300 dark:border-emerald-700/60 dark:hover:bg-emerald-900/30'
: inactiveEpisodeClass
} ${isActive ? 'cursor-default' : ''}`.trim()}
title={isWatched && !isActive ? '已观看过' : undefined}
aria-current={isActive ? 'true' : undefined}
>
{isWatched && !isActive && (
<span className='absolute top-1 right-1 h-1.5 w-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400' />
)}
{(() => {
const title = episodes_titles?.[episodeNumber - 1];
if (!title) {
return episodeNumber;
}
// 如果是 OVA 格式,直接返回完整标题
if (title.match(/^OVA\s+\d+/i)) {
return title;
}
// 如果匹配 S01E01 格式,只显示集数部分(去掉 SxxE)
const sxxexxMatch = title.match(/[Ss]\d+[Ee](\d{1,4}(?:\.\d+)?)/);
if (sxxexxMatch) {
return sxxexxMatch[1];
}
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数)
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
if (match) {
return match[1];
}
return title;
})()}
</button>
);
});
.map((episodeNumber) => (
<EpisodeButton
key={episodeNumber}
episodeNumber={episodeNumber}
isActive={episodeNumber === value}
isWatched={watchedEpisodes.has(episodeNumber)}
originalTitle={episodes_titles?.[episodeNumber - 1]}
inactiveEpisodeClass={inactiveEpisodeClass}
onSelect={handleEpisodeClick}
onShowOriginalName={showEpisodeNamePopup}
/>
));
})()}
</div>
</>
@@ -1188,6 +1336,31 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
}}
onShowToast={onShowToast}
/>
{/* 原集名 popup:移动端长按 / 桌面右键,样式对齐标题上方 aka 提示 */}
{episodeNamePopup && (
<div
className='fixed z-[1000] px-3 py-2 bg-gray-800 dark:bg-gray-900 text-white text-sm rounded-lg shadow-xl pointer-events-none max-w-[min(80vw,20rem)]'
style={{
left: episodeNamePopup.x,
top: episodeNamePopup.y,
transform:
episodeNamePopup.placement === 'top'
? 'translate(-50%, -100%)'
: 'translate(-50%, 0)',
}}
role='tooltip'
>
<div className='text-sm break-words whitespace-normal'>
{episodeNamePopup.title}
</div>
{episodeNamePopup.placement === 'top' ? (
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800 dark:border-t-gray-900' />
) : (
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-b-4 border-transparent border-b-gray-800 dark:border-b-gray-900' />
)}
</div>
)}
</div>
);
};