mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-06-11 19:43:13 +08:00
feat: change source tab
This commit is contained in:
@@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation';
|
|||||||
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
deletePlayRecord,
|
||||||
generateStorageKey,
|
generateStorageKey,
|
||||||
getAllPlayRecords,
|
getAllPlayRecords,
|
||||||
savePlayRecord,
|
savePlayRecord,
|
||||||
@@ -14,10 +15,13 @@ import {
|
|||||||
type VideoDetail,
|
type VideoDetail,
|
||||||
fetchVideoDetail,
|
fetchVideoDetail,
|
||||||
} from '@/lib/fetchVideoDetail.client';
|
} from '@/lib/fetchVideoDetail.client';
|
||||||
|
import { SearchResult } from '@/lib/types';
|
||||||
|
|
||||||
import EpisodeSelector from '@/components/EpisodeSelector';
|
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
|
||||||
|
// 直接从 types.ts 导入 SearchResult 接口
|
||||||
|
|
||||||
function PlayPageClient() {
|
function PlayPageClient() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
@@ -50,6 +54,13 @@ function PlayPageClient() {
|
|||||||
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||||||
const resumeTimeRef = useRef<number | null>(null);
|
const resumeTimeRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// 换源相关状态
|
||||||
|
const [availableSources, setAvailableSources] = useState<SearchResult[]>([]);
|
||||||
|
const [sourceSearchLoading, setSourceSearchLoading] = useState(false);
|
||||||
|
const [sourceSearchError, setSourceSearchError] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
const currentSourceRef = useRef(currentSource);
|
const currentSourceRef = useRef(currentSource);
|
||||||
const currentIdRef = useRef(currentId);
|
const currentIdRef = useRef(currentId);
|
||||||
const videoTitleRef = useRef(videoTitle);
|
const videoTitleRef = useRef(videoTitle);
|
||||||
@@ -266,6 +277,140 @@ function PlayPageClient() {
|
|||||||
initFromHistory();
|
initFromHistory();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 处理换源搜索
|
||||||
|
const handleSearchSources = async (query: string) => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setAvailableSources([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceSearchLoading(true);
|
||||||
|
setSourceSearchError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('搜索失败');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 处理搜索结果:每个数据源只展示一个,优先展示与title同名的结果
|
||||||
|
const processedResults: SearchResult[] = [];
|
||||||
|
const sourceMap = new Map<string, SearchResult[]>();
|
||||||
|
|
||||||
|
// 按数据源分组
|
||||||
|
data.results?.forEach((result: SearchResult) => {
|
||||||
|
if (!sourceMap.has(result.source)) {
|
||||||
|
sourceMap.set(result.source, []);
|
||||||
|
}
|
||||||
|
const list = sourceMap.get(result.source);
|
||||||
|
if (list) {
|
||||||
|
list.push(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为每个数据源选择最佳结果
|
||||||
|
sourceMap.forEach((results) => {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
|
||||||
|
// 只选择和当前视频标题完全匹配的结果,如果有年份,还需要年份完全匹配
|
||||||
|
const exactMatch = results.find(
|
||||||
|
(result) =>
|
||||||
|
result.title.toLowerCase() === videoTitle.toLowerCase() &&
|
||||||
|
(videoYear
|
||||||
|
? result.year.toLowerCase() === videoYear.toLowerCase()
|
||||||
|
: true) &&
|
||||||
|
detail?.episodes.length &&
|
||||||
|
((detail?.episodes.length === 1 && result.episodes.length === 1) ||
|
||||||
|
(detail?.episodes.length > 1 && result.episodes.length > 1))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exactMatch) {
|
||||||
|
processedResults.push(exactMatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 直接使用 SearchResult 格式
|
||||||
|
setAvailableSources(processedResults);
|
||||||
|
} catch (err) {
|
||||||
|
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
|
||||||
|
setAvailableSources([]);
|
||||||
|
} finally {
|
||||||
|
setSourceSearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理换源
|
||||||
|
const handleSourceChange = async (
|
||||||
|
newSource: string,
|
||||||
|
newId: string,
|
||||||
|
newTitle: string
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
// 记录当前播放进度(仅在同一集数切换时恢复)
|
||||||
|
const currentPlayTime =
|
||||||
|
artPlayerRef.current?.video?.currentTime ||
|
||||||
|
artPlayerRef.current?.currentTime ||
|
||||||
|
0;
|
||||||
|
console.log('换源前当前播放时间:', currentPlayTime);
|
||||||
|
|
||||||
|
// 显示加载状态
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 清除前一个历史记录
|
||||||
|
if (currentSource && currentId) {
|
||||||
|
try {
|
||||||
|
await deletePlayRecord(currentSource, currentId);
|
||||||
|
console.log('已清除前一个播放记录');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('清除播放记录失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取新源的详情
|
||||||
|
const newDetail = await fetchVideoDetail({
|
||||||
|
source: newSource,
|
||||||
|
id: newId,
|
||||||
|
fallbackTitle: newTitle.trim(),
|
||||||
|
fallbackYear: videoYear,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 尝试跳转到当前正在播放的集数
|
||||||
|
let targetIndex = currentEpisodeIndex;
|
||||||
|
|
||||||
|
// 如果当前集数超出新源的范围,则跳转到第一集
|
||||||
|
if (!newDetail.episodes || targetIndex >= newDetail.episodes.length) {
|
||||||
|
targetIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍然是同一集数且播放进度有效,则在播放器就绪后恢复到原始进度
|
||||||
|
if (targetIndex === currentEpisodeIndex && currentPlayTime > 1) {
|
||||||
|
resumeTimeRef.current = currentPlayTime;
|
||||||
|
} else {
|
||||||
|
// 否则从头开始播放,防止影响后续选集逻辑
|
||||||
|
resumeTimeRef.current = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新URL参数(不刷新页面)
|
||||||
|
const newUrl = new URL(window.location.href);
|
||||||
|
newUrl.searchParams.set('source', newSource);
|
||||||
|
newUrl.searchParams.set('id', newId);
|
||||||
|
window.history.replaceState({}, '', newUrl.toString());
|
||||||
|
|
||||||
|
setVideoTitle(newDetail.title || newTitle);
|
||||||
|
setVideoCover(newDetail.poster);
|
||||||
|
setCurrentSource(newSource);
|
||||||
|
setCurrentId(newId);
|
||||||
|
setDetail(newDetail);
|
||||||
|
setCurrentEpisodeIndex(targetIndex);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '换源失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 处理集数切换
|
// 处理集数切换
|
||||||
const handleEpisodeChange = (episodeNumber: number) => {
|
const handleEpisodeChange = (episodeNumber: number) => {
|
||||||
if (episodeNumber >= 0 && episodeNumber < totalEpisodes) {
|
if (episodeNumber >= 0 && episodeNumber < totalEpisodes) {
|
||||||
@@ -515,11 +660,15 @@ function PlayPageClient() {
|
|||||||
// 监听播放器事件
|
// 监听播放器事件
|
||||||
artPlayerRef.current.on('ready', () => {
|
artPlayerRef.current.on('ready', () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听视频可播放事件,这时恢复播放进度更可靠
|
||||||
|
artPlayerRef.current.on('video:canplay', () => {
|
||||||
// 若存在需要恢复的播放进度,则跳转
|
// 若存在需要恢复的播放进度,则跳转
|
||||||
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
|
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
|
||||||
try {
|
try {
|
||||||
artPlayerRef.current.video.currentTime = resumeTimeRef.current;
|
artPlayerRef.current.video.currentTime = resumeTimeRef.current;
|
||||||
|
console.log('成功恢复播放进度到:', resumeTimeRef.current);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('恢复播放进度失败:', err);
|
console.warn('恢复播放进度失败:', err);
|
||||||
}
|
}
|
||||||
@@ -652,29 +801,42 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout activePath='/play'>
|
<PageLayout activePath='/play'>
|
||||||
<div className='flex flex-col gap-6 py-4 px-10 md:px-24'>
|
<div className='flex flex-col gap-6 py-4 px-5 md:px-20'>
|
||||||
{/* 第一行:影片标题 */}
|
{/* 第一行:影片标题 */}
|
||||||
<div className='py-1'>
|
<div className='py-1'>
|
||||||
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
<h1 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||||
{videoTitle || '影片标题'}
|
{videoTitle || '影片标题'}
|
||||||
|
{totalEpisodes > 1 && (
|
||||||
|
<span className='text-gray-500 dark:text-gray-400'>
|
||||||
|
{` > 第 ${currentEpisodeIndex + 1} 集`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{/* 第二行:播放器和选集 */}
|
{/* 第二行:播放器和选集 */}
|
||||||
<div className='grid grid-cols-1 md:grid-cols-4 gap-4 md:h-[650px]'>
|
<div className='grid grid-cols-1 md:grid-cols-4 gap-4 md:h-[650px]'>
|
||||||
{/* 播放器 */}
|
{/* 播放器 */}
|
||||||
<div className='md:col-span-3 h-[400px] md:h-full'>
|
<div className='md:col-span-3 h-[300px] md:h-full'>
|
||||||
<div
|
<div
|
||||||
id='artplayer-container'
|
id='artplayer-container'
|
||||||
className='bg-black w-full h-full rounded-2xl overflow-hidden border border-white/10'
|
className='bg-black w-full h-full rounded-2xl overflow-hidden border border-white/0 dark:border-white/30'
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 选集 */}
|
{/* 选集和换源 */}
|
||||||
<div className='md:col-span-1 h-full md:overflow-hidden'>
|
<div className='md:col-span-1 h-[300px] md:h-full md:overflow-hidden'>
|
||||||
<EpisodeSelector
|
<EpisodeSelector
|
||||||
totalEpisodes={totalEpisodes}
|
totalEpisodes={totalEpisodes}
|
||||||
value={currentEpisodeIndex + 1}
|
value={currentEpisodeIndex + 1}
|
||||||
onChange={handleEpisodeChange}
|
onChange={handleEpisodeChange}
|
||||||
|
onSourceChange={handleSourceChange}
|
||||||
|
currentSource={currentSource}
|
||||||
|
currentId={currentId}
|
||||||
|
videoTitle={videoTitle}
|
||||||
|
availableSources={availableSources}
|
||||||
|
onSearchSources={handleSearchSources}
|
||||||
|
sourceSearchLoading={sourceSearchLoading}
|
||||||
|
sourceSearchError={sourceSearchError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
|
||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -6,6 +8,8 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
|
import { SearchResult } from '@/lib/types';
|
||||||
|
|
||||||
interface EpisodeSelectorProps {
|
interface EpisodeSelectorProps {
|
||||||
/** 总集数 */
|
/** 总集数 */
|
||||||
totalEpisodes: number;
|
totalEpisodes: number;
|
||||||
@@ -15,22 +19,42 @@ interface EpisodeSelectorProps {
|
|||||||
value?: number;
|
value?: number;
|
||||||
/** 用户点击选集后的回调 */
|
/** 用户点击选集后的回调 */
|
||||||
onChange?: (episodeNumber: number) => void;
|
onChange?: (episodeNumber: number) => void;
|
||||||
/** 额外 className */
|
/** 换源相关 */
|
||||||
className?: string;
|
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> = ({
|
const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||||
totalEpisodes,
|
totalEpisodes,
|
||||||
episodesPerPage = 50,
|
episodesPerPage = 50,
|
||||||
value = 1,
|
value = 1,
|
||||||
onChange,
|
onChange,
|
||||||
className = '',
|
onSourceChange,
|
||||||
|
currentSource,
|
||||||
|
currentId,
|
||||||
|
videoTitle,
|
||||||
|
availableSources = [],
|
||||||
|
onSearchSources,
|
||||||
|
sourceSearchLoading = false,
|
||||||
|
sourceSearchError = null,
|
||||||
}) => {
|
}) => {
|
||||||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||||||
|
|
||||||
|
// 主要的 tab 状态:'episodes' 或 'sources'
|
||||||
|
const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>(
|
||||||
|
'episodes'
|
||||||
|
);
|
||||||
|
|
||||||
// 当前分页索引(0 开始)
|
// 当前分页索引(0 开始)
|
||||||
const initialPage = Math.floor((value - 1) / episodesPerPage);
|
const initialPage = Math.floor((value - 1) / episodesPerPage);
|
||||||
const [currentPage, setCurrentPage] = useState<number>(initialPage);
|
const [currentPage, setCurrentPage] = useState<number>(initialPage);
|
||||||
@@ -65,6 +89,15 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
}, [currentPage, pageCount]);
|
}, [currentPage, pageCount]);
|
||||||
|
|
||||||
|
// 处理换源tab点击,只在点击时才搜索
|
||||||
|
const handleSourceTabClick = () => {
|
||||||
|
setActiveTab('sources');
|
||||||
|
// 只在点击时搜索,且只搜索一次
|
||||||
|
if (availableSources.length === 0 && videoTitle && onSearchSources) {
|
||||||
|
onSearchSources(videoTitle);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCategoryClick = useCallback((index: number) => {
|
const handleCategoryClick = useCallback((index: number) => {
|
||||||
setCurrentPage(index);
|
setCurrentPage(index);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -76,6 +109,13 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
[onChange]
|
[onChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSourceClick = useCallback(
|
||||||
|
(source: SearchResult) => {
|
||||||
|
onSourceChange?.(source.source, source.id, source.title);
|
||||||
|
},
|
||||||
|
[onSourceChange]
|
||||||
|
);
|
||||||
|
|
||||||
const currentStart = currentPage * episodesPerPage + 1;
|
const currentStart = currentPage * episodesPerPage + 1;
|
||||||
const currentEnd = Math.min(
|
const currentEnd = Math.min(
|
||||||
currentStart + episodesPerPage - 1,
|
currentStart + episodesPerPage - 1,
|
||||||
@@ -83,89 +123,232 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className='md:ml-6 px-6 py-0 h-full rounded-2xl bg-black/10 dark:bg-white/5 flex flex-col border border-white/0 dark:border-white/30 overflow-hidden'>
|
||||||
className={`md:ml-6 px-6 py-3 h-full rounded-2xl bg-black/10 dark:bg-white/5 flex flex-col ${className}`.trim()}
|
{/* 主要的 Tab 切换 - 无缝融入设计 */}
|
||||||
>
|
<div className='flex mb-1 -mx-6 flex-shrink-0'>
|
||||||
{/* 分类标签 */}
|
<div
|
||||||
<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'>
|
onClick={() => setActiveTab('episodes')}
|
||||||
<div className='flex-1 overflow-x-auto' ref={categoryContainerRef}>
|
className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium
|
||||||
<div className='flex gap-2 min-w-max'>
|
${
|
||||||
{categories.map((label, idx) => {
|
activeTab === 'episodes'
|
||||||
const isActive = idx === currentPage;
|
? '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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={label}
|
key={episodeNumber}
|
||||||
ref={(el) => {
|
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
||||||
buttonRefs.current[idx] = el;
|
className={`h-10 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200
|
||||||
}}
|
|
||||||
onClick={() => handleCategoryClick(idx)}
|
|
||||||
className={`w-20 relative py-2 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 text-center
|
|
||||||
${
|
${
|
||||||
isActive
|
isActive
|
||||||
? 'text-green-500 dark:text-green-400'
|
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
|
||||||
: 'text-gray-700 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400'
|
: '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()}
|
||||||
`.trim()}
|
|
||||||
>
|
>
|
||||||
{label}
|
{episodeNumber}
|
||||||
{isActive && (
|
|
||||||
<div className='absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 dark:bg-green-400' />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
|
||||||
|
|
||||||
{/* 集数网格 */}
|
{/* 换源 Tab 内容 */}
|
||||||
<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'>
|
{activeTab === 'sources' && (
|
||||||
{(() => {
|
<div className='flex flex-col h-full mt-4'>
|
||||||
const len = currentEnd - currentStart + 1;
|
{sourceSearchLoading && (
|
||||||
const episodes = Array.from({ length: len }, (_, i) =>
|
<div className='flex items-center justify-center py-8'>
|
||||||
descending ? currentEnd - i : currentStart + i
|
<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'>
|
||||||
return episodes;
|
搜索中...
|
||||||
})().map((episodeNumber) => {
|
</span>
|
||||||
const isActive = episodeNumber === value;
|
</div>
|
||||||
return (
|
)}
|
||||||
<button
|
|
||||||
key={episodeNumber}
|
{sourceSearchError && (
|
||||||
onClick={() => handleEpisodeClick(episodeNumber - 1)}
|
<div className='flex items-center justify-center py-8'>
|
||||||
className={`h-10 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200
|
<div className='text-center'>
|
||||||
${
|
<div className='text-red-500 text-2xl mb-2'>⚠️</div>
|
||||||
isActive
|
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||||
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
|
{sourceSearchError}
|
||||||
: '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'
|
</p>
|
||||||
}`.trim()}
|
</div>
|
||||||
>
|
</div>
|
||||||
{episodeNumber}
|
)}
|
||||||
</button>
|
|
||||||
);
|
{!sourceSearchLoading &&
|
||||||
})}
|
!sourceSearchError &&
|
||||||
</div>
|
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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user