Files
MoonTVPlus/src/components/EpisodeSelector.tsx

984 lines
39 KiB
TypeScript
Raw Normal View History

2025-08-12 21:50:58 +08:00
/* eslint-disable @next/next/no-img-element */
import { useRouter } from 'next/navigation';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
2025-12-15 00:16:50 +08:00
import { Settings } from 'lucide-react';
2025-08-12 21:50:58 +08:00
2025-12-02 01:00:42 +08:00
import DanmakuPanel from '@/components/DanmakuPanel';
2025-12-15 00:16:50 +08:00
import EpisodeFilterSettings from '@/components/EpisodeFilterSettings';
2026-01-05 00:52:17 +08:00
import type { DanmakuSelection, DanmakuComment } from '@/lib/danmaku/types';
2025-12-15 00:16:50 +08:00
import { SearchResult, EpisodeFilterConfig } from '@/lib/types';
2025-08-12 21:50:58 +08:00
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
// 定义视频信息类型
interface VideoInfo {
quality: string;
loadSpeed: string;
pingTime: number;
hasError?: boolean; // 添加错误状态标识
}
interface EpisodeSelectorProps {
/** 总集数 */
totalEpisodes: number;
/** 剧集标题 */
episodes_titles: string[];
/** 每页显示多少集,默认 50 */
episodesPerPage?: number;
/** 当前选中的集数1 开始) */
value?: number;
/** 用户点击选集后的回调 */
onChange?: (episodeNumber: number) => void;
/** 换源相关 */
onSourceChange?: (source: string, id: string, title: string) => void;
currentSource?: string;
currentId?: string;
videoTitle?: string;
videoYear?: string;
availableSources?: SearchResult[];
sourceSearchLoading?: boolean;
sourceSearchError?: string | null;
2025-12-29 22:12:55 +08:00
/** 后台源加载状态 */
backgroundSourcesLoading?: boolean;
2025-08-12 21:50:58 +08:00
/** 预计算的测速结果,避免重复测速 */
precomputedVideoInfo?: Map<string, VideoInfo>;
2025-12-02 01:00:42 +08:00
/** 弹幕相关 */
onDanmakuSelect?: (selection: DanmakuSelection) => void;
currentDanmakuSelection?: DanmakuSelection | null;
2026-01-05 00:52:17 +08:00
onUploadDanmaku?: (comments: DanmakuComment[]) => void;
/** 观影室房员状态 - 禁用选集和换源,但保留弹幕 */
isRoomMember?: boolean;
2025-12-15 00:16:50 +08:00
/** 集数过滤配置 */
episodeFilterConfig?: EpisodeFilterConfig | null;
onFilterConfigUpdate?: (config: EpisodeFilterConfig) => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
2025-08-12 21:50:58 +08:00
}
/**
*
*/
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
totalEpisodes,
episodes_titles,
episodesPerPage = 50,
value = 1,
onChange,
onSourceChange,
currentSource,
currentId,
videoTitle,
availableSources = [],
sourceSearchLoading = false,
sourceSearchError = null,
2025-12-29 22:12:55 +08:00
backgroundSourcesLoading = false,
2025-08-12 21:50:58 +08:00
precomputedVideoInfo,
2025-12-02 01:00:42 +08:00
onDanmakuSelect,
currentDanmakuSelection,
2026-01-05 00:52:17 +08:00
onUploadDanmaku,
isRoomMember = false,
2025-12-15 00:16:50 +08:00
episodeFilterConfig = null,
onFilterConfigUpdate,
onShowToast,
2025-08-12 21:50:58 +08:00
}) => {
const router = useRouter();
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
// 存储每个源的视频信息
const [videoInfoMap, setVideoInfoMap] = useState<Map<string, VideoInfo>>(
new Map()
);
const [attemptedSources, setAttemptedSources] = useState<Set<string>>(
new Set()
);
// 存储正在重新测试的源
const [retestingSources, setRetestingSources] = useState<Set<string>>(
new Set()
);
// 标记初始测速是否已完成
const [initialTestingCompleted, setInitialTestingCompleted] = useState(false);
2025-08-12 21:50:58 +08:00
// 使用 ref 来避免闭包问题
const attemptedSourcesRef = useRef<Set<string>>(new Set());
const videoInfoMapRef = useRef<Map<string, VideoInfo>>(new Map());
// 同步状态到 ref
useEffect(() => {
attemptedSourcesRef.current = attemptedSources;
}, [attemptedSources]);
useEffect(() => {
videoInfoMapRef.current = videoInfoMap;
}, [videoInfoMap]);
2025-12-02 01:00:42 +08:00
// 主要的 tab 状态:'danmaku' | 'episodes' | 'sources'
// 默认显示选集选项卡,但如果是房员则显示弹幕
const [activeTab, setActiveTab] = useState<'danmaku' | 'episodes' | 'sources'>(
isRoomMember ? 'danmaku' : 'episodes'
);
// 当房员状态变化时,自动切换到弹幕选项卡
useEffect(() => {
if (isRoomMember && (activeTab === 'episodes' || activeTab === 'sources')) {
setActiveTab('danmaku');
}
}, [isRoomMember, activeTab]);
2025-08-12 21:50:58 +08:00
// 当前分页索引0 开始)
const initialPage = Math.floor((value - 1) / episodesPerPage);
const [currentPage, setCurrentPage] = useState<number>(initialPage);
// 是否倒序显示
const [descending, setDescending] = useState<boolean>(false);
2025-12-15 00:16:50 +08:00
// 集数过滤设置弹窗状态
const [showFilterSettings, setShowFilterSettings] = useState<boolean>(false);
// 集数过滤逻辑
const isEpisodeFiltered = useCallback(
(episodeNumber: number): boolean => {
if (!episodeFilterConfig || episodeFilterConfig.rules.length === 0) {
return false;
}
// 获取集数标题
const title = episodes_titles?.[episodeNumber - 1];
if (!title) return false;
// 检查每个启用的规则
for (const rule of episodeFilterConfig.rules) {
if (!rule.enabled) continue;
try {
if (rule.type === 'normal') {
// 普通模式:字符串包含匹配
if (title.includes(rule.keyword)) {
return true;
}
} else if (rule.type === 'regex') {
// 正则模式:正则表达式匹配
if (new RegExp(rule.keyword).test(title)) {
return true;
}
}
} catch (e) {
console.error('集数过滤规则错误:', e);
}
}
return false;
},
[episodeFilterConfig, episodes_titles]
);
2025-08-12 21:50:58 +08:00
// 根据 descending 状态计算实际显示的分页索引
const displayPage = useMemo(() => {
if (descending) {
return pageCount - 1 - currentPage;
}
return currentPage;
}, [currentPage, descending, pageCount]);
// 获取视频信息的函数 - 移除 attemptedSources 依赖避免不必要的重新创建
const getVideoInfo = useCallback(async (source: SearchResult) => {
const sourceKey = `${source.source}-${source.id}`;
// 使用 ref 获取最新的状态,避免闭包问题
if (attemptedSourcesRef.current.has(sourceKey)) {
return;
}
// 获取第一集的URL
if (!source.episodes || source.episodes.length === 0) {
return;
}
const episodeUrl =
source.episodes.length > 1 ? source.episodes[1] : source.episodes[0];
// 标记为已尝试
setAttemptedSources((prev) => new Set(prev).add(sourceKey));
try {
const info = await getVideoResolutionFromM3u8(episodeUrl);
setVideoInfoMap((prev) => new Map(prev).set(sourceKey, info));
} catch (error) {
// 失败时保存错误状态
setVideoInfoMap((prev) =>
new Map(prev).set(sourceKey, {
quality: '错误',
loadSpeed: '未知',
pingTime: 0,
hasError: true,
})
);
}
}, []);
// 当有预计算结果时先合并到videoInfoMap中
useEffect(() => {
if (precomputedVideoInfo && precomputedVideoInfo.size > 0) {
// 原子性地更新两个状态,避免时序问题
setVideoInfoMap((prev) => {
const newMap = new Map(prev);
precomputedVideoInfo.forEach((value, key) => {
newMap.set(key, value);
});
return newMap;
});
setAttemptedSources((prev) => {
const newSet = new Set(prev);
precomputedVideoInfo.forEach((info, key) => {
if (!info.hasError) {
newSet.add(key);
}
});
return newSet;
});
// 同步更新 ref确保 getVideoInfo 能立即看到更新
precomputedVideoInfo.forEach((info, key) => {
if (!info.hasError) {
attemptedSourcesRef.current.add(key);
}
});
}
}, [precomputedVideoInfo]);
2025-08-26 00:15:57 +08:00
// 读取本地"优选和测速"开关,默认开启
2025-08-12 21:50:58 +08:00
const [optimizationEnabled] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('enableOptimization');
if (saved !== null) {
try {
return JSON.parse(saved);
} catch {
/* ignore */
}
}
}
return true;
});
// 当切换到换源tab并且有源数据时异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发
useEffect(() => {
const fetchVideoInfosInBatches = async () => {
if (
!optimizationEnabled || // 若关闭测速则直接退出
activeTab !== 'sources' ||
2025-12-24 11:53:32 +08:00
availableSources.length === 0 ||
2026-01-03 01:04:38 +08:00
currentSource === 'openlist' || // 私人影库不进行测速
currentSource === 'emby' // Emby 不进行测速
2025-08-12 21:50:58 +08:00
)
return;
// 筛选出尚未测速的播放源
const pendingSources = availableSources.filter((source) => {
const sourceKey = `${source.source}-${source.id}`;
return !attemptedSourcesRef.current.has(sourceKey);
});
if (pendingSources.length === 0) return;
const batchSize = Math.ceil(pendingSources.length / 2);
for (let start = 0; start < pendingSources.length; start += batchSize) {
const batch = pendingSources.slice(start, start + batchSize);
await Promise.all(batch.map(getVideoInfo));
}
// 初始测速完成后,标记为已完成
if (!initialTestingCompleted) {
setInitialTestingCompleted(true);
}
2025-08-12 21:50:58 +08:00
};
fetchVideoInfosInBatches();
// 依赖项保持与之前一致
2025-12-24 11:53:32 +08:00
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted, currentSource]);
2025-08-12 21:50:58 +08:00
2025-12-29 22:12:55 +08:00
// 监听后台加载完成,触发自动测速
const prevBackgroundLoadingRef = useRef<boolean>(false);
useEffect(() => {
// 当后台加载从 true 变为 false 时(即加载完成)
if (prevBackgroundLoadingRef.current && !backgroundSourcesLoading) {
// 如果当前选项卡在换源位置,触发测速
2026-01-03 01:04:38 +08:00
if (activeTab === 'sources' && optimizationEnabled && currentSource !== 'openlist' && currentSource !== 'emby') {
2025-12-29 22:12:55 +08:00
// 筛选出尚未测速的播放源
const pendingSources = availableSources.filter((source) => {
const sourceKey = `${source.source}-${source.id}`;
return !attemptedSourcesRef.current.has(sourceKey);
});
if (pendingSources.length > 0) {
const batchSize = Math.ceil(pendingSources.length / 2);
const fetchInBatches = async () => {
for (let start = 0; start < pendingSources.length; start += batchSize) {
const batch = pendingSources.slice(start, start + batchSize);
await Promise.all(batch.map(getVideoInfo));
}
if (!initialTestingCompleted) {
setInitialTestingCompleted(true);
}
};
fetchInBatches();
}
}
}
// 更新前一次的加载状态
prevBackgroundLoadingRef.current = backgroundSourcesLoading;
}, [backgroundSourcesLoading, activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted, currentSource]);
2025-08-12 21:50:58 +08:00
// 升序分页标签
const categoriesAsc = useMemo(() => {
return Array.from({ length: pageCount }, (_, i) => {
const start = i * episodesPerPage + 1;
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
return { start, end };
});
}, [pageCount, episodesPerPage, totalEpisodes]);
// 根据 descending 状态决定分页标签的排序和内容
const categories = useMemo(() => {
if (descending) {
// 倒序时label 也倒序显示
return [...categoriesAsc]
.reverse()
.map(({ start, end }) => `${end}-${start}`);
}
return categoriesAsc.map(({ start, end }) => `${start}-${end}`);
}, [categoriesAsc, descending]);
const categoryContainerRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
2025-08-26 00:15:57 +08:00
// 添加鼠标悬停状态管理
const [isCategoryHovered, setIsCategoryHovered] = useState(false);
// 阻止页面竖向滚动
const preventPageScroll = useCallback((e: WheelEvent) => {
if (isCategoryHovered) {
e.preventDefault();
}
}, [isCategoryHovered]);
// 处理滚轮事件,实现横向滚动
const handleWheel = useCallback((e: WheelEvent) => {
if (isCategoryHovered && categoryContainerRef.current) {
e.preventDefault(); // 阻止默认的竖向滚动
const container = categoryContainerRef.current;
const scrollAmount = e.deltaY * 2; // 调整滚动速度
// 根据滚轮方向进行横向滚动
container.scrollBy({
left: scrollAmount,
behavior: 'smooth'
});
}
}, [isCategoryHovered]);
// 添加全局wheel事件监听器
useEffect(() => {
if (isCategoryHovered) {
// 鼠标悬停时阻止页面滚动
document.addEventListener('wheel', preventPageScroll, { passive: false });
document.addEventListener('wheel', handleWheel, { passive: false });
} else {
// 鼠标离开时恢复页面滚动
document.removeEventListener('wheel', preventPageScroll);
document.removeEventListener('wheel', handleWheel);
}
return () => {
document.removeEventListener('wheel', preventPageScroll);
document.removeEventListener('wheel', handleWheel);
};
}, [isCategoryHovered, preventPageScroll, handleWheel]);
2025-08-12 21:50:58 +08:00
// 当分页切换时,将激活的分页标签滚动到视口中间
useEffect(() => {
const btn = buttonRefs.current[displayPage];
const container = categoryContainerRef.current;
if (btn && container) {
// 手动计算滚动位置,只滚动分页标签容器
const containerRect = container.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
const scrollLeft = container.scrollLeft;
// 计算按钮相对于容器的位置
const btnLeft = btnRect.left - containerRect.left + scrollLeft;
const btnWidth = btnRect.width;
const containerWidth = containerRect.width;
// 计算目标滚动位置,使按钮居中
const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2;
// 平滑滚动到目标位置
container.scrollTo({
left: targetScrollLeft,
behavior: 'smooth',
});
}
}, [displayPage, pageCount]);
// 处理换源tab点击只在点击时才搜索
const handleSourceTabClick = () => {
setActiveTab('sources');
};
const handleCategoryClick = useCallback(
(index: number) => {
if (descending) {
// 在倒序时,需要将显示索引转换为实际索引
setCurrentPage(pageCount - 1 - index);
} else {
setCurrentPage(index);
}
},
[descending, pageCount]
);
const handleEpisodeClick = useCallback(
(episodeNumber: number) => {
onChange?.(episodeNumber);
},
[onChange]
);
const handleSourceClick = useCallback(
(source: SearchResult) => {
onSourceChange?.(source.source, source.id, source.title);
},
[onSourceChange]
);
// 解析网速字符串,转换为 KB/s 数值用于排序
const parseSpeedToKBps = useCallback((speedStr: string): number => {
if (!speedStr || speedStr === '未知' || speedStr === '测量中...') {
return -1; // 无效速度返回 -1排在最后
}
const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/);
if (!match) {
return -1;
}
const value = parseFloat(match[1]);
const unit = match[2];
// 统一转换为 KB/s
return unit === 'MB/s' ? value * 1024 : value;
}, []);
// 重新测试单个源
const handleRetestSource = useCallback(
async (source: SearchResult, e: React.MouseEvent) => {
e.stopPropagation(); // 阻止事件冒泡,避免触发换源
const sourceKey = `${source.source}-${source.id}`;
// 标记为正在测试
setRetestingSources((prev) => new Set(prev).add(sourceKey));
// 从已尝试列表中移除,允许重新测试
setAttemptedSources((prev) => {
const newSet = new Set(prev);
newSet.delete(sourceKey);
return newSet;
});
// 同步更新 ref
attemptedSourcesRef.current.delete(sourceKey);
// 执行测试
try {
await getVideoInfo(source);
} finally {
// 无论成功或失败,都移除测试标记
setRetestingSources((prev) => {
const newSet = new Set(prev);
newSet.delete(sourceKey);
return newSet;
});
}
},
[getVideoInfo]
);
2025-08-12 21:50:58 +08:00
const currentStart = currentPage * episodesPerPage + 1;
const currentEnd = Math.min(
currentStart + episodesPerPage - 1,
totalEpisodes
);
return (
<div className='md:ml-2 px-4 py-0 h-full rounded-xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
{/* 主要的 Tab 切换 - 无缝融入设计 */}
<div className='flex mb-1 -mx-6 flex-shrink-0'>
2025-12-02 01:00:42 +08:00
{/* 选集选项卡 - 仅在多集时显示 */}
2025-08-12 21:50:58 +08:00
{totalEpisodes > 1 && (
<div
onClick={() => !isRoomMember && setActiveTab('episodes')}
className={`flex-1 py-3 px-6 text-center transition-all duration-200 font-medium relative
${isRoomMember ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
2025-08-17 18:55:22 +08:00
${activeTab === 'episodes'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
2025-08-12 21:50:58 +08:00
}
`.trim()}
>
{isRoomMember && <span className="ml-1 text-xs">🔒</span>}
2025-08-12 21:50:58 +08:00
</div>
)}
2025-12-02 01:00:42 +08:00
{/* 换源选项卡 */}
2025-08-12 21:50:58 +08:00
<div
onClick={() => !isRoomMember && handleSourceTabClick()}
className={`flex-1 py-3 px-6 text-center transition-all duration-200 font-medium relative
${isRoomMember ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}
2025-08-17 18:55:22 +08:00
${activeTab === 'sources'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
2025-08-12 21:50:58 +08:00
}
`.trim()}
>
{isRoomMember && <span className="ml-1 text-xs">🔒</span>}
2025-08-12 21:50:58 +08:00
</div>
2025-12-02 01:00:42 +08:00
{/* 弹幕选项卡 */}
<div
onClick={() => setActiveTab('danmaku')}
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
${activeTab === 'danmaku'
? 'text-green-600 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3'
}
`.trim()}
>
</div>
2025-08-12 21:50:58 +08:00
</div>
2025-12-02 01:00:42 +08:00
{/* 弹幕 Tab 内容 */}
{activeTab === 'danmaku' && onDanmakuSelect && (
2025-12-02 17:50:22 +08:00
<div className='flex-1 min-h-0 overflow-hidden'>
<DanmakuPanel
videoTitle={videoTitle || ''}
currentEpisodeIndex={value - 1}
onDanmakuSelect={onDanmakuSelect}
currentSelection={currentDanmakuSelection || null}
2026-01-05 00:52:17 +08:00
onUploadDanmaku={onUploadDanmaku}
2025-12-02 17:50:22 +08:00
/>
</div>
2025-12-02 01:00:42 +08:00
)}
2025-08-12 21:50:58 +08:00
{/* 选集 Tab 内容 */}
{activeTab === 'episodes' && (
<>
{/* 分类标签 */}
<div className='flex items-center gap-4 mb-4 border-b border-gray-300 dark:border-gray-700 -mx-6 px-6 flex-shrink-0'>
2025-08-26 00:15:57 +08:00
<div
className='flex-1 overflow-x-auto'
ref={categoryContainerRef}
onMouseEnter={() => setIsCategoryHovered(true)}
onMouseLeave={() => setIsCategoryHovered(false)}
>
2025-08-12 21:50:58 +08:00
<div className='flex gap-2 min-w-max'>
{categories.map((label, idx) => {
const isActive = idx === displayPage;
return (
<button
key={label}
ref={(el) => {
buttonRefs.current[idx] = el;
}}
onClick={() => handleCategoryClick(idx)}
className={`w-20 relative py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center
2025-08-17 18:55:22 +08:00
${isActive
? 'text-green-500 dark:text-green-400'
: 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
2025-08-12 21:50:58 +08:00
}
`.trim()}
>
{label}
{isActive && (
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 dark:bg-green-400' />
)}
</button>
);
})}
</div>
</div>
{/* 向上/向下按钮 */}
<button
className='flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center text-gray-700 hover:text-green-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-green-400 dark:hover:bg-white/20 transition-colors transform translate-y-[-4px]'
onClick={() => {
// 切换集数排序(正序/倒序)
setDescending((prev) => !prev);
}}
>
<svg
className='w-4 h-4'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4'
/>
</svg>
</button>
2025-12-15 00:16:50 +08:00
{/* 集数屏蔽配置按钮 */}
<button
className='flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center text-gray-700 hover:text-green-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-green-400 dark:hover:bg-white/20 transition-colors transform translate-y-[-4px]'
onClick={() => setShowFilterSettings(true)}
title='集数屏蔽设置'
>
<Settings className='w-4 h-4' />
</button>
2025-08-12 21:50:58 +08:00
</div>
{/* 集数网格 */}
<div className='flex flex-wrap gap-3 overflow-y-auto flex-1 content-start pb-4'>
{(() => {
const len = currentEnd - currentStart + 1;
const episodes = Array.from({ length: len }, (_, i) =>
descending ? currentEnd - i : currentStart + i
);
2025-12-15 00:16:50 +08:00
// 过滤掉被屏蔽的集数,但保持原有索引
return episodes
.filter(episodeNumber => !isEpisodeFiltered(episodeNumber))
.map((episodeNumber) => {
const isActive = episodeNumber === value;
return (
<button
key={episodeNumber}
onClick={() => handleEpisodeClick(episodeNumber - 1)}
className={`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
${isActive
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 hover:scale-105 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
2025-12-15 00:16:50 +08:00
}`.trim()}
>
{(() => {
const title = episodes_titles?.[episodeNumber - 1];
if (!title) {
return episodeNumber;
}
2026-01-03 16:40:39 +08:00
// 如果是 OVA 格式,直接返回完整标题
if (title.match(/^OVA\s+\d+/i)) {
return title;
}
2026-01-11 00:52:52 +08:00
// 如果匹配 S01E01 格式,提取并返回
const sxxexxMatch = title.match(/[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/);
if (sxxexxMatch) {
const season = sxxexxMatch[1].padStart(2, '0');
const episode = sxxexxMatch[2];
return `S${season}E${episode}`;
}
2025-12-22 00:42:27 +08:00
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数)
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
2025-12-15 00:16:50 +08:00
if (match) {
return match[1];
}
return title;
})()}
</button>
);
});
})()}
2025-08-12 21:50:58 +08:00
</div>
</>
)}
{/* 换源 Tab 内容 */}
{activeTab === 'sources' && (
<div className='flex flex-col h-full mt-4'>
{sourceSearchLoading && (
<div className='flex items-center justify-center py-8'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
<span className='ml-2 text-sm text-gray-600 dark:text-gray-300'>
...
</span>
</div>
)}
{sourceSearchError && (
<div className='flex items-center justify-center py-8'>
<div className='text-center'>
<div className='text-red-500 text-2xl mb-2'></div>
<p className='text-sm text-red-600 dark:text-red-400'>
{sourceSearchError}
</p>
</div>
</div>
)}
{!sourceSearchLoading &&
!sourceSearchError &&
availableSources.length === 0 && (
<div className='flex items-center justify-center py-8'>
<div className='text-center'>
<div className='text-gray-400 text-2xl mb-2'>📺</div>
<p className='text-sm text-gray-600 dark:text-gray-300'>
</p>
</div>
</div>
)}
{!sourceSearchLoading &&
!sourceSearchError &&
availableSources.length > 0 && (
<div className='flex-1 overflow-y-auto space-y-2 pb-20'>
{availableSources
.sort((a, b) => {
const aIsCurrent =
a.source?.toString() === currentSource?.toString() &&
a.id?.toString() === currentId?.toString();
const bIsCurrent =
b.source?.toString() === currentSource?.toString() &&
b.id?.toString() === currentId?.toString();
// 当前源始终置顶
2025-08-12 21:50:58 +08:00
if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
// 如果初始测速已完成,按网速排序(快的在前)
if (initialTestingCompleted) {
const aKey = `${a.source}-${a.id}`;
const bKey = `${b.source}-${b.id}`;
const aInfo = videoInfoMap.get(aKey);
const bInfo = videoInfoMap.get(bKey);
const aSpeed = aInfo ? parseSpeedToKBps(aInfo.loadSpeed) : -1;
const bSpeed = bInfo ? parseSpeedToKBps(bInfo.loadSpeed) : -1;
// 速度快的排在前面(降序)
return bSpeed - aSpeed;
}
2025-08-12 21:50:58 +08:00
return 0;
})
.map((source, index) => {
const isCurrentSource =
source.source?.toString() === currentSource?.toString() &&
source.id?.toString() === currentId?.toString();
return (
<div
key={`${source.source}-${source.id}`}
onClick={() =>
!isCurrentSource && handleSourceClick(source)
}
className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative
2025-08-17 18:55:22 +08:00
${isCurrentSource
? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border'
: 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer'
2025-08-17 18:55:22 +08:00
}`.trim()}
2025-08-12 21:50:58 +08:00
>
{/* 封面 */}
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
{source.poster && (
2025-08-12 21:50:58 +08:00
<img
src={processImageUrl(source.poster)}
alt={source.title}
className='w-full h-full object-cover'
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
)}
</div>
{/* 信息区域 */}
<div className='flex-1 min-w-0 flex flex-col justify-between h-20'>
{/* 标题和分辨率 - 顶部 */}
<div className='flex items-start justify-between gap-3 h-6'>
<div className='flex-1 min-w-0 relative group/title'>
<h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100 leading-none'>
{source.title}
</h3>
{/* 标题级别的 tooltip - 第一个元素不显示 */}
{index !== 0 && (
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible group-hover/title:opacity-100 group-hover/title:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap z-[500] pointer-events-none'>
{source.title}
<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'></div>
</div>
)}
</div>
{(() => {
const sourceKey = `${source.source}-${source.id}`;
const videoInfo = videoInfoMap.get(sourceKey);
if (videoInfo && videoInfo.quality !== '未知') {
if (videoInfo.hasError) {
return (
<div className='bg-gray-500/10 dark:bg-gray-400/20 text-red-600 dark:text-red-400 px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center'>
</div>
);
} else {
// 根据分辨率设置不同颜色2K、4K为紫色1080p、720p为绿色其他为黄色
const isUltraHigh = ['4K', '2K'].includes(
videoInfo.quality
);
const isHigh = ['1080p', '720p'].includes(
videoInfo.quality
);
const textColorClasses = isUltraHigh
? 'text-purple-600 dark:text-purple-400'
: isHigh
2025-08-17 18:55:22 +08:00
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400';
2025-08-12 21:50:58 +08:00
return (
<div
className={`bg-gray-500/10 dark:bg-gray-400/20 ${textColorClasses} px-1.5 py-0 rounded text-xs flex-shrink-0 min-w-[50px] text-center`}
>
{videoInfo.quality}
</div>
);
}
}
return null;
})()}
</div>
{/* 源名称和集数信息 - 垂直居中 */}
<div className='flex items-center justify-between'>
<span className={`text-xs px-2 py-1 border rounded text-gray-700 dark:text-gray-300 ${
2026-01-11 16:59:46 +08:00
source.source === 'xiaoya' ? 'border-blue-500' : source.source === 'openlist' || source.source === 'emby' || source.source?.startsWith('emby_')
? 'border-yellow-500'
: 'border-gray-500/60'
}`}>
2025-08-12 21:50:58 +08:00
{source.source_name}
</span>
{source.episodes.length > 1 && (
<span className='text-xs text-gray-500 dark:text-gray-400 font-medium'>
{source.episodes.length}
</span>
)}
</div>
{/* 网络信息 - 底部 */}
<div className='flex items-end justify-between h-6'>
<div className='flex items-end gap-3'>
{(() => {
const sourceKey = `${source.source}-${source.id}`;
const videoInfo = videoInfoMap.get(sourceKey);
if (videoInfo) {
if (!videoInfo.hasError) {
return (
<div className='flex items-end gap-3 text-xs'>
<div className='text-green-600 dark:text-green-400 font-medium text-xs'>
{videoInfo.loadSpeed}
</div>
<div className='text-orange-600 dark:text-orange-400 font-medium text-xs'>
{videoInfo.pingTime}ms
</div>
</div>
);
} else {
return (
<div className='text-red-500/90 dark:text-red-400 font-medium text-xs'>
</div>
);
}
}
return null;
})()}
</div>
{/* 重新测试按钮 */}
2025-08-12 21:50:58 +08:00
{(() => {
2026-01-03 01:04:38 +08:00
// 私人影库和 Emby 不显示重新测试按钮
2026-01-08 00:14:07 +08:00
if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_')) {
2025-12-24 11:53:32 +08:00
return null;
}
2025-08-12 21:50:58 +08:00
const sourceKey = `${source.source}-${source.id}`;
const isTesting = retestingSources.has(sourceKey);
2025-08-12 21:50:58 +08:00
const videoInfo = videoInfoMap.get(sourceKey);
// 只有第一次测试完成后(有测速数据)才显示重新测试按钮
2025-08-12 21:50:58 +08:00
if (videoInfo) {
return (
<button
onClick={(e) => handleRetestSource(source, e)}
disabled={isTesting}
className={`text-xs font-medium transition-colors ${
isTesting
? 'text-gray-400 dark:text-gray-500 cursor-not-allowed'
: 'text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 cursor-pointer'
}`}
>
{isTesting ? '测试中...' : '重新测试'}
</button>
);
2025-08-12 21:50:58 +08:00
}
return null;
2025-08-12 21:50:58 +08:00
})()}
</div>
</div>
</div>
);
})}
2025-12-29 22:12:55 +08:00
{/* 后台加载提示 */}
{backgroundSourcesLoading && (
<div className='flex items-center justify-center py-6 border-t border-gray-300 dark:border-gray-700'>
<div className='animate-spin rounded-full h-6 w-6 border-b-2 border-green-500'></div>
<span className='ml-2 text-sm text-gray-600 dark:text-gray-300'>
...
</span>
</div>
)}
2025-08-12 21:50:58 +08:00
<div className='flex-shrink-0 mt-auto pt-2 border-t border-gray-400 dark:border-gray-700'>
<button
onClick={() => {
if (videoTitle) {
router.push(
`/search?q=${encodeURIComponent(videoTitle)}`
);
}
}}
className='w-full text-center text-xs text-gray-500 dark:text-gray-400 hover:text-green-500 dark:hover:text-green-400 transition-colors py-2'
>
</button>
</div>
</div>
)}
</div>
)}
2025-12-15 00:16:50 +08:00
{/* 集数过滤设置弹窗 */}
<EpisodeFilterSettings
isOpen={showFilterSettings}
onClose={() => setShowFilterSettings(false)}
onConfigUpdate={(config) => {
onFilterConfigUpdate?.(config);
}}
onShowToast={onShowToast}
/>
2025-08-12 21:50:58 +08:00
</div>
);
};
export default EpisodeSelector;