mirror of https://github.com/djteang/OrangeTV.git
692 lines
26 KiB
TypeScript
692 lines
26 KiB
TypeScript
/* eslint-disable @next/next/no-img-element */
|
||
|
||
import { useRouter } from 'next/navigation';
|
||
import { Button, Chip, Spinner } from '@heroui/react';
|
||
import React, {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import { SearchResult } from '@/lib/types';
|
||
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;
|
||
/** 预计算的测速结果,避免重复测速 */
|
||
precomputedVideoInfo?: Map<string, VideoInfo>;
|
||
}
|
||
|
||
/**
|
||
* 选集组件,支持分页、自动滚动聚焦当前分页标签,以及换源功能。
|
||
*/
|
||
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||
totalEpisodes,
|
||
episodes_titles,
|
||
episodesPerPage = 50,
|
||
value = 1,
|
||
onChange,
|
||
onSourceChange,
|
||
currentSource,
|
||
currentId,
|
||
videoTitle,
|
||
availableSources = [],
|
||
sourceSearchLoading = false,
|
||
sourceSearchError = null,
|
||
precomputedVideoInfo,
|
||
}) => {
|
||
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()
|
||
);
|
||
|
||
// 使用 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]);
|
||
|
||
// 主要的 tab 状态:'episodes' 或 'sources'
|
||
// 当只有一集时默认展示 "换源",并隐藏 "选集" 标签
|
||
const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>(
|
||
totalEpisodes > 1 ? 'episodes' : 'sources'
|
||
);
|
||
|
||
// 当前分页索引(0 开始)
|
||
const initialPage = Math.floor((value - 1) / episodesPerPage);
|
||
const [currentPage, setCurrentPage] = useState<number>(initialPage);
|
||
|
||
// 是否倒序显示
|
||
const [descending, setDescending] = useState<boolean>(false);
|
||
|
||
// 根据 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) {
|
||
// 失败时保存错误状态,区分不同的错误类型
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
const isNetworkRestricted = errorMessage.includes('Network access restricted') ||
|
||
errorMessage.includes('CORS') ||
|
||
errorMessage.includes('Forbidden');
|
||
|
||
// 只在开发环境下打印详细错误
|
||
if (process.env.NODE_ENV === 'development') {
|
||
console.warn(`Video info fetch failed for ${sourceKey}:`, errorMessage);
|
||
}
|
||
|
||
setVideoInfoMap((prev) =>
|
||
new Map(prev).set(sourceKey, {
|
||
quality: isNetworkRestricted ? '受限' : '未知',
|
||
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]);
|
||
|
||
// 读取本地"优选和测速"开关,默认开启
|
||
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' ||
|
||
availableSources.length === 0
|
||
)
|
||
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));
|
||
}
|
||
};
|
||
|
||
fetchVideoInfosInBatches();
|
||
// 依赖项保持与之前一致
|
||
}, [activeTab, availableSources, getVideoInfo, optimizationEnabled]);
|
||
|
||
// 升序分页标签
|
||
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)[]>([]);
|
||
|
||
// 添加鼠标悬停状态管理
|
||
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]);
|
||
|
||
// 当分页切换时,将激活的分页标签滚动到视口中间
|
||
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]
|
||
);
|
||
|
||
const currentStart = currentPage * episodesPerPage + 1;
|
||
const currentEnd = Math.min(
|
||
currentStart + episodesPerPage - 1,
|
||
totalEpisodes
|
||
);
|
||
|
||
return (
|
||
<div className='flex h-full flex-col overflow-hidden border-t border-border/70 px-4 py-0 md:ml-2'>
|
||
{/* 主要的 Tab 切换 - 无缝融入设计 */}
|
||
<div className='-mx-4 mb-2 flex flex-shrink-0 border-b border-border/70 px-4'>
|
||
{totalEpisodes > 1 && (
|
||
<div
|
||
onClick={() => setActiveTab('episodes')}
|
||
className={`flex-1 cursor-pointer border-b py-3 text-center text-[11px] font-medium uppercase tracking-[0.16em] transition-all duration-200
|
||
${activeTab === 'episodes'
|
||
? 'border-accent text-foreground'
|
||
: 'border-transparent text-muted hover:text-muted'
|
||
}
|
||
`.trim()}
|
||
>
|
||
选集
|
||
</div>
|
||
)}
|
||
<div
|
||
onClick={handleSourceTabClick}
|
||
className={`flex-1 cursor-pointer border-b py-3 text-center text-[11px] font-medium uppercase tracking-[0.16em] transition-all duration-200
|
||
${activeTab === 'sources'
|
||
? 'border-accent text-foreground'
|
||
: 'border-transparent text-muted hover:text-muted'
|
||
}
|
||
`.trim()}
|
||
>
|
||
换源
|
||
</div>
|
||
</div>
|
||
|
||
{/* 选集 Tab 内容 */}
|
||
{activeTab === 'episodes' && (
|
||
<>
|
||
{/* 分类标签 */}
|
||
<div className='-mx-4 mb-4 flex flex-shrink-0 items-center gap-4 border-b border-border/70 px-4'>
|
||
<div
|
||
className='flex-1 overflow-x-auto'
|
||
ref={categoryContainerRef}
|
||
onMouseEnter={() => setIsCategoryHovered(true)}
|
||
onMouseLeave={() => setIsCategoryHovered(false)}
|
||
>
|
||
<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;
|
||
}}
|
||
onPress={() => handleCategoryClick(idx)}
|
||
variant={isActive ? 'primary' : 'ghost'}
|
||
size='sm'
|
||
className='w-20 flex-shrink-0'
|
||
>
|
||
{label}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
{/* 向上/向下按钮 */}
|
||
<Button
|
||
isIconOnly
|
||
size='sm'
|
||
variant='tertiary'
|
||
className='flex-shrink-0 translate-y-[-4px]'
|
||
onPress={() => {
|
||
// 切换集数排序(正序/倒序)
|
||
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>
|
||
</div>
|
||
|
||
{/* 集数网格 */}
|
||
<div className='content-start flex flex-1 flex-wrap gap-3 overflow-y-auto pb-4'>
|
||
{(() => {
|
||
const len = currentEnd - currentStart + 1;
|
||
const episodes = Array.from({ length: len }, (_, i) =>
|
||
descending ? currentEnd - i : currentStart + i
|
||
);
|
||
return episodes;
|
||
})().map((episodeNumber) => {
|
||
const isActive = episodeNumber === value;
|
||
return (
|
||
<Button
|
||
key={episodeNumber}
|
||
onPress={() => handleEpisodeClick(episodeNumber - 1)}
|
||
variant={isActive ? 'primary' : 'tertiary'}
|
||
size='sm'
|
||
className='min-w-10'
|
||
>
|
||
{(() => {
|
||
const title = episodes_titles?.[episodeNumber - 1];
|
||
if (!title) {
|
||
return episodeNumber;
|
||
}
|
||
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字
|
||
const match = title.match(/(?:第)?(\d+)(?:集|话)/);
|
||
if (match) {
|
||
return match[1];
|
||
}
|
||
return title;
|
||
})()}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 换源 Tab 内容 */}
|
||
{activeTab === 'sources' && (
|
||
<div className='flex flex-col h-full mt-4'>
|
||
{sourceSearchLoading && (
|
||
<div className='flex items-center justify-center py-8'>
|
||
<Spinner size='sm' />
|
||
<span className='ml-3 text-xs uppercase tracking-[0.16em] text-muted'>
|
||
搜索中
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{sourceSearchError && (
|
||
<div className='flex items-center justify-center py-8'>
|
||
<div className='text-center'>
|
||
<div className='mb-3 text-xs uppercase tracking-[0.16em] text-danger'>Source error</div>
|
||
<p className='text-sm text-danger'>
|
||
{sourceSearchError}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!sourceSearchLoading &&
|
||
!sourceSearchError &&
|
||
availableSources.length === 0 && (
|
||
<div className='flex items-center justify-center py-8'>
|
||
<div className='text-center'>
|
||
<div className='mb-3 text-xs uppercase tracking-[0.16em] text-muted'>No sources</div>
|
||
<p className='text-sm text-muted'>
|
||
暂无可用的换源
|
||
</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();
|
||
if (aIsCurrent && !bIsCurrent) return -1;
|
||
if (!aIsCurrent && bIsCurrent) return 1;
|
||
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={`relative flex select-none items-start gap-3 border-t px-2 py-3 transition-all duration-200
|
||
${isCurrentSource
|
||
? 'border-accent bg-accent/10'
|
||
: 'cursor-pointer border-border/70 hover:border-accent/30 hover:bg-surface/50'
|
||
}`.trim()}
|
||
>
|
||
{/* 封面 */}
|
||
<div className='h-20 w-12 flex-shrink-0 overflow-hidden border border-border/70 bg-surface/60'>
|
||
{source.episodes && source.episodes.length > 0 && (
|
||
<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='truncate text-base font-medium leading-none text-foreground'>
|
||
{source.title}
|
||
</h3>
|
||
{/* 标题级别的 tooltip - 第一个元素不显示 */}
|
||
{index !== 0 && (
|
||
<div className='invisible pointer-events-none absolute bottom-full left-1/2 z-[500] mb-2 -translate-x-1/2 border border-border/70 bg-surface/95 px-3 py-1 text-xs text-foreground opacity-0 transition-all duration-200 ease-out delay-100 whitespace-nowrap group-hover/title:visible group-hover/title:opacity-100'>
|
||
{source.title}
|
||
<div className='absolute left-1/2 top-full h-2 w-px -translate-x-1/2 bg-border/70'></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{(() => {
|
||
const sourceKey = `${source.source}-${source.id}`;
|
||
const videoInfo = videoInfoMap.get(sourceKey);
|
||
|
||
if (videoInfo && videoInfo.quality !== '未知') {
|
||
if (videoInfo.hasError) {
|
||
return (
|
||
<div className='min-w-[50px] flex-shrink-0 border border-border/70 px-1.5 py-0 text-center text-xs text-danger'>
|
||
检测失败
|
||
</div>
|
||
);
|
||
} else {
|
||
// 根据分辨率设置不同颜色:2K、4K为紫色,1080p、720p为绿色,其他为黄色
|
||
const isUltraHigh = ['4K', '2K'].includes(
|
||
videoInfo.quality
|
||
);
|
||
const isHigh = ['1080p', '720p'].includes(
|
||
videoInfo.quality
|
||
);
|
||
const textColorClasses = isUltraHigh
|
||
? 'text-accent'
|
||
: isHigh
|
||
? 'text-success'
|
||
: 'text-warning';
|
||
|
||
return (
|
||
<div
|
||
className={`min-w-[50px] flex-shrink-0 border border-border/70 px-1.5 py-0 text-center text-xs ${textColorClasses}`}
|
||
>
|
||
{videoInfo.quality}
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
})()}
|
||
</div>
|
||
|
||
{/* 源名称和集数信息 - 垂直居中 */}
|
||
<div className='flex items-center justify-between'>
|
||
<Chip size='sm' variant='secondary'>
|
||
<Chip.Label>{source.source_name}</Chip.Label>
|
||
</Chip>
|
||
{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 h-6'>
|
||
{(() => {
|
||
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'>
|
||
<Chip size='sm' color='accent' variant='soft'>
|
||
<Chip.Label>{videoInfo.loadSpeed}</Chip.Label>
|
||
</Chip>
|
||
<Chip size='sm' color='warning' variant='soft'>
|
||
<Chip.Label>{videoInfo.pingTime}ms</Chip.Label>
|
||
</Chip>
|
||
</div>
|
||
);
|
||
} else {
|
||
return (
|
||
<div className='text-red-500/90 dark:text-red-400 font-medium text-xs'>
|
||
无测速数据
|
||
</div>
|
||
); // 占位div
|
||
}
|
||
}
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
<div className='flex-shrink-0 mt-auto pt-2 border-t border-gray-400 dark:border-gray-700'>
|
||
<Button
|
||
variant='tertiary'
|
||
fullWidth
|
||
onPress={() => {
|
||
if (videoTitle) {
|
||
router.push(
|
||
`/search?q=${encodeURIComponent(videoTitle)}`
|
||
);
|
||
}
|
||
}}
|
||
>
|
||
影片匹配有误?点击去搜索
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default EpisodeSelector;
|