From 5d90eb8ce7f4fb5f8c10d7151b462785421ef181 Mon Sep 17 00:00:00 2001 From: mtvpls <247332661+mtvpls@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:06:12 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9B=86=E6=95=B0=E9=95=BF=E6=8C=89=E6=88=96?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E6=98=BE=E7=A4=BA=E5=8E=9F=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/EpisodeSelector.tsx | 263 ++++++++++++++++++++++++----- 1 file changed, 218 insertions(+), 45 deletions(-) diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index e8856fb..26286fc 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -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 = ({ + episodeNumber, + isActive, + isWatched, + originalTitle, + inactiveEpisodeClass, + onSelect, + onShowOriginalName, +}) => { + const buttonRef = useRef(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 ( + + ); +}; // 定义视频信息类型 interface VideoInfo { @@ -207,6 +323,71 @@ const EpisodeSelector: React.FC = ({ // 标记是否正在进行初始测速 const [isInitialTesting, setIsInitialTesting] = useState(false); const [watchedEpisodes, setWatchedEpisodes] = useState>(new Set()); + // 选集按钮长按/右键:原集名 popup(样式参考标题上方 aka 提示) + const [episodeNamePopup, setEpisodeNamePopup] = + useState(null); + const episodeNamePopupTimerRef = useRef | 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>(new Set()); @@ -842,51 +1023,18 @@ const EpisodeSelector: React.FC = ({ // 过滤掉被屏蔽的集数,但保持原有索引 return episodes .filter(episodeNumber => !isEpisodeFiltered(episodeNumber)) - .map((episodeNumber) => { - const isActive = episodeNumber === value; - const isWatched = watchedEpisodes.has(episodeNumber); - return ( - - ); - }); + .map((episodeNumber) => ( + + )); })()} @@ -1188,6 +1336,31 @@ const EpisodeSelector: React.FC = ({ }} onShowToast={onShowToast} /> + + {/* 原集名 popup:移动端长按 / 桌面右键,样式对齐标题上方 aka 提示 */} + {episodeNamePopup && ( +
+
+ {episodeNamePopup.title} +
+ {episodeNamePopup.placement === 'top' ? ( +
+ ) : ( +
+ )} +
+ )}
); };