集数长按或右键显示原名
This commit is contained in:
@@ -21,6 +21,122 @@ import { getVideoResolutionFromM3u8 } from '@/lib/utils';
|
|||||||
import DanmakuPanel from '@/components/DanmakuPanel';
|
import DanmakuPanel from '@/components/DanmakuPanel';
|
||||||
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
|
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
|
||||||
import ProxyImage from '@/components/ProxyImage';
|
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 {
|
interface VideoInfo {
|
||||||
@@ -207,6 +323,71 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
// 标记是否正在进行初始测速
|
// 标记是否正在进行初始测速
|
||||||
const [isInitialTesting, setIsInitialTesting] = useState(false);
|
const [isInitialTesting, setIsInitialTesting] = useState(false);
|
||||||
const [watchedEpisodes, setWatchedEpisodes] = useState<Set<number>>(new Set());
|
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 来避免闭包问题
|
// 使用 ref 来避免闭包问题
|
||||||
const attemptedSourcesRef = useRef<Set<string>>(new Set());
|
const attemptedSourcesRef = useRef<Set<string>>(new Set());
|
||||||
@@ -842,51 +1023,18 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
// 过滤掉被屏蔽的集数,但保持原有索引
|
// 过滤掉被屏蔽的集数,但保持原有索引
|
||||||
return episodes
|
return episodes
|
||||||
.filter(episodeNumber => !isEpisodeFiltered(episodeNumber))
|
.filter(episodeNumber => !isEpisodeFiltered(episodeNumber))
|
||||||
.map((episodeNumber) => {
|
.map((episodeNumber) => (
|
||||||
const isActive = episodeNumber === value;
|
<EpisodeButton
|
||||||
const isWatched = watchedEpisodes.has(episodeNumber);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={episodeNumber}
|
key={episodeNumber}
|
||||||
disabled={isActive}
|
episodeNumber={episodeNumber}
|
||||||
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
isActive={episodeNumber === value}
|
||||||
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
|
isWatched={watchedEpisodes.has(episodeNumber)}
|
||||||
${isActive
|
originalTitle={episodes_titles?.[episodeNumber - 1]}
|
||||||
? 'bg-green-500 text-white border-green-400 shadow-lg shadow-green-500/25 dark:bg-green-600'
|
inactiveEpisodeClass={inactiveEpisodeClass}
|
||||||
: isWatched
|
onSelect={handleEpisodeClick}
|
||||||
? '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'
|
onShowOriginalName={showEpisodeNamePopup}
|
||||||
: 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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -1188,6 +1336,31 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
}}
|
}}
|
||||||
onShowToast={onShowToast}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user