mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-22 10:34:42 +08:00
390 lines
15 KiB
TypeScript
390 lines
15 KiB
TypeScript
/* eslint-disable @next/next/no-img-element */
|
||
|
||
import { useRouter } from 'next/navigation';
|
||
import React, {
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
|
||
import { SearchResult } from '@/lib/types';
|
||
|
||
interface EpisodeSelectorProps {
|
||
/** 总集数 */
|
||
totalEpisodes: number;
|
||
/** 每页显示多少集,默认 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[];
|
||
onSearchSources?: (query: string) => void;
|
||
sourceSearchLoading?: boolean;
|
||
sourceSearchError?: string | null;
|
||
}
|
||
|
||
/**
|
||
* 选集组件,支持分页、自动滚动聚焦当前分页标签,以及换源功能。
|
||
*/
|
||
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||
totalEpisodes,
|
||
episodesPerPage = 50,
|
||
value = 1,
|
||
onChange,
|
||
onSourceChange,
|
||
currentSource,
|
||
currentId,
|
||
videoTitle,
|
||
availableSources = [],
|
||
onSearchSources,
|
||
sourceSearchLoading = false,
|
||
sourceSearchError = null,
|
||
}) => {
|
||
const router = useRouter();
|
||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||
|
||
// 主要的 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);
|
||
|
||
// 升序分页标签
|
||
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]);
|
||
|
||
// 分页标签始终保持升序
|
||
const categories = categoriesAsc;
|
||
|
||
const categoryContainerRef = useRef<HTMLDivElement>(null);
|
||
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||
|
||
// 当分页切换时,将激活的分页标签滚动到视口中间
|
||
useEffect(() => {
|
||
const btn = buttonRefs.current[currentPage];
|
||
if (btn) {
|
||
btn.scrollIntoView({
|
||
behavior: 'smooth',
|
||
inline: 'center',
|
||
block: 'nearest',
|
||
});
|
||
}
|
||
}, [currentPage, pageCount]);
|
||
|
||
// 处理换源tab点击,只在点击时才搜索
|
||
const handleSourceTabClick = () => {
|
||
setActiveTab('sources');
|
||
// 只在点击时搜索,且只搜索一次
|
||
if (availableSources.length === 0 && videoTitle && onSearchSources) {
|
||
onSearchSources(videoTitle);
|
||
}
|
||
};
|
||
|
||
const handleCategoryClick = useCallback((index: number) => {
|
||
setCurrentPage(index);
|
||
}, []);
|
||
|
||
const handleEpisodeClick = useCallback(
|
||
(episodeNumber: number) => {
|
||
onChange?.(episodeNumber);
|
||
},
|
||
[onChange]
|
||
);
|
||
|
||
const handleSourceClick = useCallback(
|
||
(source: SearchResult) => {
|
||
onSourceChange?.(source.source, source.id, source.title);
|
||
},
|
||
[onSourceChange]
|
||
);
|
||
|
||
// 如果组件初始即显示 "换源",自动触发搜索一次
|
||
useEffect(() => {
|
||
if (
|
||
activeTab === 'sources' &&
|
||
availableSources.length === 0 &&
|
||
videoTitle &&
|
||
onSearchSources
|
||
) {
|
||
onSearchSources(videoTitle);
|
||
}
|
||
// 只在依赖变化时尝试,availableSources 长度变化可阻止重复搜索
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [activeTab, availableSources.length, videoTitle]);
|
||
|
||
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'>
|
||
{totalEpisodes > 1 && (
|
||
<div
|
||
onClick={() => setActiveTab('episodes')}
|
||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||
${
|
||
activeTab === 'episodes'
|
||
? 'text-green-500 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>
|
||
)}
|
||
<div
|
||
onClick={handleSourceTabClick}
|
||
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||
${
|
||
activeTab === 'sources'
|
||
? 'text-green-500 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>
|
||
</div>
|
||
|
||
{/* 选集 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'>
|
||
<div className='flex-1 overflow-x-auto' ref={categoryContainerRef}>
|
||
<div className='flex gap-2 min-w-max'>
|
||
{categories.map((label, idx) => {
|
||
const isActive = idx === currentPage;
|
||
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
|
||
${
|
||
isActive
|
||
? 'text-green-500 dark:text-green-400'
|
||
: 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
|
||
}
|
||
`.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>
|
||
</div>
|
||
|
||
{/* 集数网格 */}
|
||
<div className='grid grid-cols-[repeat(auto-fill,minmax(40px,1fr))] auto-rows-[40px] gap-x-3 gap-y-3 overflow-y-auto h-full 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}
|
||
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
||
className={`h-10 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200
|
||
${
|
||
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-white/10 dark:text-gray-300 dark:hover:bg-white/20'
|
||
}`.trim()}
|
||
>
|
||
{episodeNumber}
|
||
</button>
|
||
);
|
||
})}
|
||
</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();
|
||
if (aIsCurrent && !bIsCurrent) return -1;
|
||
if (!aIsCurrent && bIsCurrent) return 1;
|
||
return 0;
|
||
})
|
||
.map((source) => {
|
||
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 p-3 rounded-lg cursor-pointer transition-all duration-200
|
||
${
|
||
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]'
|
||
}`.trim()}
|
||
>
|
||
{/* 封面 */}
|
||
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
|
||
{source.episodes && source.episodes.length > 0 && (
|
||
<img
|
||
src={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'>
|
||
<div className='flex items-start justify-between'>
|
||
<div className='flex-1 min-w-0'>
|
||
<h3 className='font-medium text-base truncate text-gray-900 dark:text-gray-100'>
|
||
{source.title}
|
||
</h3>
|
||
<div className='flex items-center gap-2 mt-1'>
|
||
<span className='text-xs px-2 py-1 border border-gray-500/60 rounded text-gray-700 dark:text-gray-300'>
|
||
{source.source_name}
|
||
</span>
|
||
</div>
|
||
{source.episodes.length > 1 && (
|
||
<span className='text-xs text-gray-500 dark:text-gray-400 mt-1 pl-[2px]'>
|
||
共 {source.episodes.length} 集
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
<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>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default EpisodeSelector;
|