mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-22 02:24:44 +08:00
@@ -29,9 +29,10 @@
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@upstash/redis": "^1.25.0",
|
||||
"@vidstack/react": "^1.12.13",
|
||||
"artplayer": "^5.2.3",
|
||||
"clsx": "^2.0.0",
|
||||
"framer-motion": "^12.18.1",
|
||||
"hls.js": "^1.6.5",
|
||||
"hls.js": "^1.6.6",
|
||||
"lucide-react": "^0.438.0",
|
||||
"media-icons": "^1.1.5",
|
||||
"next": "^14.2.23",
|
||||
@@ -42,11 +43,11 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^5.4.0",
|
||||
"redis": "^4.6.7",
|
||||
"sweetalert2": "^11.11.0",
|
||||
"swiper": "^11.2.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vidstack": "^0.6.15",
|
||||
"zod": "^3.24.1",
|
||||
"sweetalert2": "^11.11.0"
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^16.3.0",
|
||||
|
||||
27
pnpm-lock.yaml
generated
27
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
'@vidstack/react':
|
||||
specifier: ^1.12.13
|
||||
version: 1.12.13(@types/react@18.3.23)(react@18.3.1)
|
||||
artplayer:
|
||||
specifier: ^5.2.3
|
||||
version: 5.2.3
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
@@ -42,8 +45,8 @@ importers:
|
||||
specifier: ^12.18.1
|
||||
version: 12.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
hls.js:
|
||||
specifier: ^1.6.5
|
||||
version: 1.6.5
|
||||
specifier: ^1.6.6
|
||||
version: 1.6.6
|
||||
lucide-react:
|
||||
specifier: ^0.438.0
|
||||
version: 0.438.0(react@18.3.1)
|
||||
@@ -2412,6 +2415,9 @@ packages:
|
||||
resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
artplayer@5.2.3:
|
||||
resolution: {integrity: sha512-WaOZQrpZn/L+GgI2f0TEsoAL3Wb+v16Mu0JmWh7qKFYuvr11WNt3dWhWeIaCfoHy3NtkCWM9jTP+xwwsxdElZQ==}
|
||||
|
||||
as-table@1.0.55:
|
||||
resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==}
|
||||
|
||||
@@ -3857,8 +3863,8 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hls.js@1.6.5:
|
||||
resolution: {integrity: sha512-KMn5n7JBK+olC342740hDPHnGWfE8FiHtGMOdJPfUjRdARTWj9OB+8c13fnsf9sk1VtpuU2fKSgUjHvg4rNbzQ==}
|
||||
hls.js@1.6.6:
|
||||
resolution: {integrity: sha512-S4uTCwTHOtImW+/jxMjzG7udbHy5z682YQRbm/4f7VXuVNEoGBRjPJnD3Fxrufomdhzdtv24KnxRhPMXSvL6Fw==}
|
||||
|
||||
hosted-git-info@2.8.9:
|
||||
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
||||
@@ -4888,6 +4894,9 @@ packages:
|
||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
option-validator@2.0.6:
|
||||
resolution: {integrity: sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -8961,6 +8970,10 @@ snapshots:
|
||||
|
||||
arrify@1.0.1: {}
|
||||
|
||||
artplayer@5.2.3:
|
||||
dependencies:
|
||||
option-validator: 2.0.6
|
||||
|
||||
as-table@1.0.55:
|
||||
dependencies:
|
||||
printable-characters: 1.0.42
|
||||
@@ -10497,7 +10510,7 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hls.js@1.6.5: {}
|
||||
hls.js@1.6.6: {}
|
||||
|
||||
hosted-git-info@2.8.9: {}
|
||||
|
||||
@@ -11786,6 +11799,10 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-fn: 2.1.0
|
||||
|
||||
option-validator@2.0.6:
|
||||
dependencies:
|
||||
kind-of: 6.0.3
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps, no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Heart, LinkIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import { isFavorited, toggleFavorite } from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
|
||||
function AggregatePageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const query = searchParams.get('q') || '';
|
||||
const title = searchParams.get('title') || '';
|
||||
const year = searchParams.get('year') || '';
|
||||
const type = searchParams.get('type') || '';
|
||||
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
setError('缺少搜索关键词');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/search?q=${encodeURIComponent(query.trim())}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error('搜索失败');
|
||||
}
|
||||
const data = await res.json();
|
||||
const all: SearchResult[] = data.results || [];
|
||||
const map = new Map<string, SearchResult[]>();
|
||||
all.forEach((r) => {
|
||||
// 根据传入参数进行精确匹配:
|
||||
// 1. 如果提供了 title,则按 title 精确匹配,否则按 query 精确匹配;
|
||||
// 2. 如果还提供了 year,则额外按 year 精确匹配。
|
||||
const titleMatch = title ? r.title === title : r.title === query;
|
||||
const yearMatch = year ? r.year === year : true;
|
||||
if (!titleMatch || !yearMatch) {
|
||||
return;
|
||||
}
|
||||
// 如果还传入了 type,则按 type 精确匹配
|
||||
if (type === 'tv' && r.episodes.length === 1) {
|
||||
return;
|
||||
}
|
||||
if (type === 'movie' && r.episodes.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const key = `${r.title}-${r.year}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push(r);
|
||||
map.set(key, arr);
|
||||
});
|
||||
if (map.size === 0 && type) {
|
||||
// 无匹配,忽略 type 做重新匹配
|
||||
all.forEach((r) => {
|
||||
const titleMatch = title ? r.title === title : r.title === query;
|
||||
const yearMatch = year ? r.year === year : true;
|
||||
if (!titleMatch || !yearMatch) {
|
||||
return;
|
||||
}
|
||||
const key = `${r.title}-${r.year}`;
|
||||
const arr = map.get(key) || [];
|
||||
arr.push(r);
|
||||
map.set(key, arr);
|
||||
});
|
||||
}
|
||||
if (map.size == 1) {
|
||||
setResults(Array.from(map.values()).flat());
|
||||
} else if (map.size > 1) {
|
||||
// 存在多个匹配,跳转到搜索页
|
||||
router.push(`/search?q=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '搜索失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [query, router]);
|
||||
|
||||
// 选出信息最完整的字段
|
||||
const chooseString = (vals: (string | undefined)[]): string | undefined => {
|
||||
return vals.reduce<string | undefined>((best, v) => {
|
||||
if (!v) return best;
|
||||
if (!best) return v;
|
||||
return v.length > best.length ? v : best;
|
||||
}, undefined);
|
||||
};
|
||||
// 出现次数最多的非 0 数字
|
||||
const chooseNumber = (vals: (number | undefined)[]): number | undefined => {
|
||||
const countMap = new Map<number, number>();
|
||||
vals.forEach((v) => {
|
||||
if (v !== undefined && v !== 0) {
|
||||
countMap.set(v, (countMap.get(v) || 0) + 1);
|
||||
}
|
||||
});
|
||||
let selected: number | undefined = undefined;
|
||||
let maxCount = 0;
|
||||
countMap.forEach((cnt, num) => {
|
||||
if (cnt > maxCount) {
|
||||
maxCount = cnt;
|
||||
selected = num;
|
||||
}
|
||||
});
|
||||
return selected;
|
||||
};
|
||||
|
||||
const aggregatedInfo = {
|
||||
title: title || query,
|
||||
cover: chooseString(results.map((d) => d.poster)),
|
||||
desc: chooseString(results.map((d) => d.desc)),
|
||||
type: chooseString(results.map((d) => d.type_name)),
|
||||
year: chooseString(results.map((d) => d.year)),
|
||||
remarks: chooseString(results.map((d) => d.class)),
|
||||
douban_id: chooseNumber(results.map((d) => d.douban_id)),
|
||||
};
|
||||
|
||||
const infoReady = Boolean(
|
||||
aggregatedInfo.cover ||
|
||||
aggregatedInfo.desc ||
|
||||
aggregatedInfo.type ||
|
||||
aggregatedInfo.year ||
|
||||
aggregatedInfo.remarks
|
||||
);
|
||||
|
||||
const uniqueSources = Array.from(
|
||||
new Map(results.map((r) => [r.source, r])).values()
|
||||
);
|
||||
|
||||
// 详情映射,便于快速获取每个源的集数
|
||||
const sourceDetailMap = new Map(results.map((d) => [d.source, d]));
|
||||
|
||||
// 新增:播放源卡片组件,包含收藏逻辑
|
||||
const SourceCard = ({ src }: { src: SearchResult }) => {
|
||||
const d = sourceDetailMap.get(src.source);
|
||||
const epCount = d ? d.episodes.length : src.episodes.length;
|
||||
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
|
||||
// 初次加载检查收藏状态
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const fav = await isFavorited(src.source, src.id);
|
||||
setFavorited(fav);
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
})();
|
||||
}, [src.source, src.id]);
|
||||
|
||||
// 切换收藏状态
|
||||
const handleToggleFavorite = async (
|
||||
e: React.MouseEvent<HTMLSpanElement | SVGElement, MouseEvent>
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
const newState = await toggleFavorite(src.source, src.id, {
|
||||
title: src.title,
|
||||
source_name: src.source_name,
|
||||
year: src.year,
|
||||
cover: src.poster,
|
||||
total_episodes: src.episodes.length,
|
||||
save_time: Date.now(),
|
||||
});
|
||||
setFavorited(newState);
|
||||
} catch {
|
||||
/* 忽略错误 */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
key={src.source}
|
||||
href={`/play?source=${src.source}&id=${
|
||||
src.id
|
||||
}&title=${encodeURIComponent(src.title.trim())}${
|
||||
src.year ? `&year=${src.year}` : ''
|
||||
}&from=aggregate`}
|
||||
className='group relative flex items-center justify-center w-full h-14 bg-gray-500/80 hover:bg-green-500 dark:bg-gray-700/80 dark:hover:bg-green-600 rounded-lg transition-colors'
|
||||
>
|
||||
{/* 收藏爱心 */}
|
||||
<span
|
||||
onClick={handleToggleFavorite}
|
||||
title={favorited ? '移除收藏' : '加入收藏'}
|
||||
className={`absolute top-[2px] left-1 inline-flex items-center justify-center cursor-pointer transition-opacity duration-200 ${
|
||||
favorited ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 ${
|
||||
favorited ? 'text-red-500' : 'text-white/90'
|
||||
}`}
|
||||
strokeWidth={2}
|
||||
fill={favorited ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{/* 名称 */}
|
||||
<span className='px-1 text-white text-sm font-medium truncate whitespace-nowrap'>
|
||||
{src.source_name}
|
||||
</span>
|
||||
{/* 集数徽标 */}
|
||||
{epCount && epCount > 1 ? (
|
||||
<span className='absolute top-[2px] right-1 text-[10px] font-semibold text-green-900 bg-green-300/90 rounded-full px-1 pointer-events-none'>
|
||||
{epCount}集
|
||||
</span>
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/aggregate'>
|
||||
<div className='flex flex-col min-h-full px-2 sm:px-10 pt-4 sm:pt-8 pb-[calc(3.5rem+env(safe-area-inset-bottom))] overflow-visible'>
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500'></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='text-red-500 text-center'>
|
||||
<div className='text-lg font-semibold mb-2'>加载失败</div>
|
||||
<div className='text-sm'>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !infoReady ? (
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='text-gray-500 text-center'>
|
||||
<div className='text-lg font-semibold mb-2'>未找到匹配结果</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
{/* 主信息区:左图右文 */}
|
||||
<div className='relative flex flex-col md:flex-row gap-8 mb-0 sm:mb-8 bg-transparent rounded-xl p-2 sm:p-6 md:items-start'>
|
||||
{/* 返回按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.back();
|
||||
}}
|
||||
className='absolute top-0 left-0 -translate-x-[40%] -translate-y-[30%] sm:-translate-x-[180%] sm:-translate-y-1/2 p-2 rounded transition-colors'
|
||||
>
|
||||
<svg
|
||||
className='h-5 w-5 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-500 transition-colors'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M15 19l-7-7 7-7'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* 封面 */}
|
||||
<div className='flex-shrink-0 w-full max-w-[200px] sm:max-w-none md:w-72 mx-auto'>
|
||||
<Image
|
||||
src={aggregatedInfo.cover || '/images/placeholder.png'}
|
||||
alt={aggregatedInfo.title}
|
||||
width={288}
|
||||
height={432}
|
||||
className='w-full rounded-xl object-cover'
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
{/* 右侧信息 */}
|
||||
<div
|
||||
className='flex-1 flex flex-col min-h-0'
|
||||
style={{ height: '430px' }}
|
||||
>
|
||||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
|
||||
{aggregatedInfo.title}
|
||||
{aggregatedInfo.douban_id && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${aggregatedInfo.douban_id}/`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='ml-2'
|
||||
>
|
||||
<LinkIcon className='w-4 h-4' strokeWidth={2} />
|
||||
</a>
|
||||
)}
|
||||
</h1>
|
||||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||||
{aggregatedInfo.remarks && (
|
||||
<span className='text-green-600 font-semibold'>
|
||||
{aggregatedInfo.remarks}
|
||||
</span>
|
||||
)}
|
||||
{aggregatedInfo.year && <span>{aggregatedInfo.year}</span>}
|
||||
{aggregatedInfo.type && <span>{aggregatedInfo.type}</span>}
|
||||
</div>
|
||||
<div
|
||||
className='mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{aggregatedInfo.desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 选播放源 */}
|
||||
{uniqueSources.length > 0 && (
|
||||
<div className='mt-0 sm:mt-8 bg-transparent rounded-xl p-2 sm:p-6'>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<div className='text-xl font-semibold'>选择播放源</div>
|
||||
<div className='text-gray-400 ml-2'>
|
||||
共 {uniqueSources.length} 个
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-2 sm:grid-cols-[repeat(auto-fill,_minmax(6rem,_1fr))] sm:gap-4 justify-start'>
|
||||
{uniqueSources.map((src) => (
|
||||
<SourceCard key={src.source} src={src} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AggregatePage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AggregatePageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps, no-console */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Heart, LinkIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
|
||||
import type { PlayRecord } from '@/lib/db.client';
|
||||
import {
|
||||
generateStorageKey,
|
||||
getAllPlayRecords,
|
||||
isFavorited,
|
||||
toggleFavorite,
|
||||
} from '@/lib/db.client';
|
||||
import {
|
||||
type VideoDetail,
|
||||
fetchVideoDetail,
|
||||
} from '@/lib/fetchVideoDetail.client';
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
|
||||
function DetailPageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const [detail, setDetail] = useState<VideoDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playRecord, setPlayRecord] = useState<PlayRecord | null>(null);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
// 是否倒序显示选集
|
||||
const [reverseEpisodeOrder, setReverseEpisodeOrder] = useState(false);
|
||||
|
||||
const fallbackTitle = searchParams.get('title') || '';
|
||||
const fallbackYear = searchParams.get('year') || '';
|
||||
|
||||
// 格式化剩余时间(如 1h 50m)
|
||||
const formatDuration = (seconds: number) => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (h) parts.push(`${h}h`);
|
||||
if (m) parts.push(`${m}m`);
|
||||
if (parts.length === 0) parts.push('0m');
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const source = searchParams.get('source');
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!source || !id) {
|
||||
setError('缺少必要参数');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// 获取视频详情
|
||||
const detailData = await fetchVideoDetail({
|
||||
source,
|
||||
id,
|
||||
fallbackTitle: fallbackTitle.trim(),
|
||||
fallbackYear,
|
||||
});
|
||||
setDetail(detailData);
|
||||
|
||||
// 获取播放记录
|
||||
const allRecords = await getAllPlayRecords();
|
||||
const key = generateStorageKey(source, id);
|
||||
setPlayRecord(allRecords[key] || null);
|
||||
|
||||
// 检查收藏状态
|
||||
try {
|
||||
const fav = await isFavorited(source, id);
|
||||
setFavorited(fav);
|
||||
} catch (checkErr) {
|
||||
console.error('检查收藏状态失败:', checkErr);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [searchParams]);
|
||||
|
||||
// 切换收藏状态
|
||||
const handleToggleFavorite = async () => {
|
||||
const source = searchParams.get('source');
|
||||
const id = searchParams.get('id');
|
||||
if (!source || !id || !detail) return;
|
||||
|
||||
try {
|
||||
const newState = await toggleFavorite(source, id, {
|
||||
title: detail.title,
|
||||
source_name: detail.source_name,
|
||||
year: detail.year || fallbackYear || '',
|
||||
cover: detail.poster || '',
|
||||
total_episodes: detail.episodes.length || 1,
|
||||
save_time: Date.now(),
|
||||
});
|
||||
setFavorited(newState);
|
||||
} catch (err) {
|
||||
console.error('切换收藏失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout activePath='/detail'>
|
||||
<div className='flex flex-col min-h-full px-2 sm:px-10 pt-4 sm:pt-8 pb-[calc(3.5rem+env(safe-area-inset-bottom))] overflow-visible'>
|
||||
{/* 顶部返回按钮已移入右侧信息容器 */}
|
||||
{loading ? (
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500'></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='text-red-500 text-center'>
|
||||
<div className='text-lg font-semibold mb-2'>加载失败</div>
|
||||
<div className='text-sm'>{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !detail ? (
|
||||
<div className='flex items-center justify-center min-h-[60vh]'>
|
||||
<div className='text-gray-500 text-center'>
|
||||
<div className='text-lg font-semibold mb-2'>未找到视频详情</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='max-w-[95%] mx-auto'>
|
||||
{/* 主信息区:左图右文 */}
|
||||
<div className='relative flex flex-col md:flex-row gap-8 mb-0 sm:mb-8 bg-transparent rounded-xl p-2 sm:p-6 md:items-start'>
|
||||
{/* 返回按钮放置在主信息区左上角 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
window.history.back();
|
||||
}}
|
||||
className='absolute top-0 left-0 -translate-x-[40%] -translate-y-[30%] sm:-translate-x-[180%] sm:-translate-y-1/2 p-2 rounded transition-colors'
|
||||
>
|
||||
<svg
|
||||
className='h-5 w-5 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-500 transition-colors'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<path
|
||||
d='M15 19l-7-7 7-7'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* 封面 */}
|
||||
<div className='flex-shrink-0 w-full max-w-[200px] sm:max-w-none md:w-72 mx-auto'>
|
||||
<Image
|
||||
src={detail.poster || '/images/placeholder.png'}
|
||||
alt={detail.title || fallbackTitle}
|
||||
width={288}
|
||||
height={432}
|
||||
className='w-full rounded-xl object-cover'
|
||||
style={{ aspectRatio: '2/3' }}
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
{/* 右侧信息 */}
|
||||
<div
|
||||
className='flex-1 flex flex-col min-h-0'
|
||||
style={{ height: '430px' }}
|
||||
>
|
||||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0 text-center md:text-left w-full'>
|
||||
{detail.title || fallbackTitle}
|
||||
{detail.douban_id && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${detail.douban_id}/`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className='ml-2'
|
||||
>
|
||||
<LinkIcon className='w-4 h-4' strokeWidth={2} />
|
||||
</a>
|
||||
)}
|
||||
</h1>
|
||||
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
|
||||
{detail.class && (
|
||||
<span className='text-green-600 font-semibold'>
|
||||
{detail.class}
|
||||
</span>
|
||||
)}
|
||||
{(detail.year || fallbackYear) && (
|
||||
<span>{detail.year || fallbackYear}</span>
|
||||
)}
|
||||
{detail.source_name && (
|
||||
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
|
||||
{detail.source_name}
|
||||
</span>
|
||||
)}
|
||||
{detail.type_name && <span>{detail.type_name}</span>}
|
||||
</div>
|
||||
{/* 按钮区域 */}
|
||||
<div className='flex items-center gap-4 mb-4 flex-shrink-0'>
|
||||
{playRecord ? (
|
||||
<>
|
||||
{/* 恢复播放 */}
|
||||
<a
|
||||
href={`/play?source=${searchParams.get(
|
||||
'source'
|
||||
)}&id=${searchParams.get(
|
||||
'id'
|
||||
)}&title=${encodeURIComponent(detail.title)}${
|
||||
detail.year || fallbackYear
|
||||
? `&year=${detail.year || fallbackYear}`
|
||||
: ''
|
||||
}`}
|
||||
className='flex items-center justify-center gap-2 px-6 py-2 bg-green-500 hover:bg-green-600 rounded-lg transition-colors text-white'
|
||||
>
|
||||
<div className='w-0 h-0 border-t-[6px] border-t-transparent border-l-[10px] border-l-white border-b-[6px] border-b-transparent'></div>
|
||||
<span>恢复播放</span>
|
||||
</a>
|
||||
{/* 从头开始 */}
|
||||
<a
|
||||
href={`/play?source=${searchParams.get(
|
||||
'source'
|
||||
)}&id=${searchParams.get(
|
||||
'id'
|
||||
)}&index=1&position=0&title=${encodeURIComponent(
|
||||
detail.title
|
||||
)}${
|
||||
detail.year || fallbackYear
|
||||
? `&year=${detail.year || fallbackYear}`
|
||||
: ''
|
||||
}`}
|
||||
className='hidden sm:flex items-center justify-center gap-2 px-6 py-2 bg-gray-500 hover:bg-gray-600 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg transition-colors text-white'
|
||||
>
|
||||
<div className='w-0 h-0 border-t-[6px] border-t-transparent border-l-[10px] border-l-white border-b-[6px] border-b-transparent'></div>
|
||||
<span>从头开始</span>
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 播放 */}
|
||||
<a
|
||||
href={`/play?source=${searchParams.get(
|
||||
'source'
|
||||
)}&id=${searchParams.get(
|
||||
'id'
|
||||
)}&index=1&position=0&title=${encodeURIComponent(
|
||||
detail.title
|
||||
)}${
|
||||
detail.year || fallbackYear
|
||||
? `&year=${detail.year || fallbackYear}`
|
||||
: ''
|
||||
}`}
|
||||
className='flex items-center justify-center gap-2 px-6 py-2 bg-green-500 hover:bg-green-600 rounded-lg transition-colors text-white'
|
||||
>
|
||||
<div className='w-0 h-0 border-t-[6px] border-t-transparent border-l-[10px] border-l-white border-b-[6px] border-b-transparent'></div>
|
||||
<span>播放</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{/* 爱心按钮 */}
|
||||
<button
|
||||
onClick={handleToggleFavorite}
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-full transition-colors ${
|
||||
favorited
|
||||
? 'bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500'
|
||||
: 'bg-gray-400 hover:bg-gray-500 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Heart
|
||||
className={`h-5 w-5 stroke-[2] ${
|
||||
favorited ? 'text-red-500' : 'text-white'
|
||||
}`}
|
||||
fill={favorited ? 'currentColor' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/* 播放记录进度条 */}
|
||||
{playRecord && (
|
||||
<div className='mb-4 flex items-center gap-3 w-full max-w-sm'>
|
||||
{/* 进度条 */}
|
||||
<div className='flex-1 h-1 bg-gray-600 rounded-sm overflow-hidden'>
|
||||
<div
|
||||
className='h-full bg-green-500'
|
||||
style={{
|
||||
width: `${
|
||||
(playRecord.play_time / playRecord.total_time) * 100
|
||||
}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
{/* 剩余时间 */}
|
||||
<span className='text-gray-600/60 dark:text-gray-400/60 text-xs whitespace-nowrap'>
|
||||
{playRecord.total_episodes > 1
|
||||
? `第${playRecord.index}集 剩余 `
|
||||
: '剩余 '}
|
||||
{formatDuration(
|
||||
playRecord.total_time - playRecord.play_time
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.desc && (
|
||||
<div
|
||||
className='mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{detail.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 选集按钮区 */}
|
||||
{detail.episodes && detail.episodes.length > 0 && (
|
||||
<div className='mt-0 sm:mt-8 bg-transparent rounded-xl p-2 sm:p-6'>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<div className='text-xl font-semibold'>选集</div>
|
||||
<div className='text-gray-400 ml-2'>
|
||||
共 {detail.episodes.length} 集
|
||||
</div>
|
||||
{/* 倒序切换 */}
|
||||
<span
|
||||
onClick={() => setReverseEpisodeOrder((prev) => !prev)}
|
||||
className={`ml-4 text-sm cursor-pointer select-none transition-colors ${
|
||||
reverseEpisodeOrder
|
||||
? 'text-green-500'
|
||||
: 'text-gray-400 hover:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
倒序
|
||||
</span>
|
||||
</div>
|
||||
<div className='grid grid-cols-3 gap-2 sm:grid-cols-[repeat(auto-fill,_minmax(6rem,_6rem))] sm:gap-4 justify-start'>
|
||||
{(reverseEpisodeOrder
|
||||
? Array.from(
|
||||
{ length: detail.episodes.length },
|
||||
(_, i) => i
|
||||
).reverse()
|
||||
: Array.from(
|
||||
{ length: detail.episodes.length },
|
||||
(_, i) => i
|
||||
)
|
||||
).map((idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={`/play?source=${searchParams.get(
|
||||
'source'
|
||||
)}&id=${searchParams.get('id')}&index=${
|
||||
idx + 1
|
||||
}&position=0&title=${encodeURIComponent(detail.title)}${
|
||||
detail.year || fallbackYear
|
||||
? `&year=${detail.year || fallbackYear}`
|
||||
: ''
|
||||
}`}
|
||||
className='bg-gray-500/80 hover:bg-green-500 dark:bg-gray-700/80 dark:hover:bg-green-600 text-white px-5 py-2 rounded-lg transition-colors text-base font-medium w-24 text-center'
|
||||
>
|
||||
{idx + 1}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DetailPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<DetailPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -153,20 +153,9 @@ div[data-media-provider] video {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Vidstack Menu 自定义样式:背景黑色,文字白色 */
|
||||
.vds-menu-items {
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vds-radio {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.vds-radio:hover {
|
||||
background-color: rgba(245, 245, 245, 0.1) !important;
|
||||
}
|
||||
|
||||
.vds-radio .vds-icon {
|
||||
color: #fff !important;
|
||||
.art-poster {
|
||||
background-size: contain !important; /* 使图片完整展示 */
|
||||
background-position: center center !important; /* 居中显示 */
|
||||
background-repeat: no-repeat !important; /* 防止重复 */
|
||||
background-color: #000 !important; /* 其余区域填充为黑色 */
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -186,11 +186,7 @@ function SearchPageClient() {
|
||||
? aggregatedResults.map(([mapKey, group]) => {
|
||||
return (
|
||||
<div key={`agg-${mapKey}`} className='w-full'>
|
||||
<AggregateCard
|
||||
items={group}
|
||||
query={searchQuery}
|
||||
year={group[0].year}
|
||||
/>
|
||||
<AggregateCard items={group} year={group[0].year} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -19,7 +19,6 @@ interface SearchResult {
|
||||
|
||||
interface AggregateCardProps {
|
||||
/** 同一标题下的多个搜索结果 */
|
||||
query?: string;
|
||||
year?: string;
|
||||
items: SearchResult[];
|
||||
}
|
||||
@@ -58,11 +57,7 @@ function PlayCircleSolid({
|
||||
* 点击播放按钮 -> 跳到第一个源播放
|
||||
* 点击卡片其他区域 -> 跳到聚合详情页 (/aggregate)
|
||||
*/
|
||||
const AggregateCard: React.FC<AggregateCardProps> = ({
|
||||
query = '',
|
||||
year = 0,
|
||||
items,
|
||||
}) => {
|
||||
const AggregateCard: React.FC<AggregateCardProps> = ({ year = 0, items }) => {
|
||||
// 使用列表中的第一个结果做展示 & 播放
|
||||
const first = items[0];
|
||||
const [playHover, setPlayHover] = useState(false);
|
||||
@@ -118,11 +113,9 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/aggregate?q=${encodeURIComponent(
|
||||
query.trim()
|
||||
)}&title=${encodeURIComponent(first.title)}${
|
||||
year ? `&year=${encodeURIComponent(year)}` : ''
|
||||
}&type=${mostFrequentEpisodes > 1 ? 'tv' : 'movie'}`}
|
||||
href={`/play?source=${first.source}&id=${
|
||||
first.id
|
||||
}&title=${encodeURIComponent(first.title)}${year ? `&year=${year}` : ''}`}
|
||||
>
|
||||
<div className='group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out'>
|
||||
{/* 封面图片 2:3 */}
|
||||
@@ -162,7 +155,7 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
||||
first.id
|
||||
}&title=${encodeURIComponent(first.title)}${
|
||||
year ? `&year=${year}` : ''
|
||||
}&from=aggregate`
|
||||
}`
|
||||
);
|
||||
}}
|
||||
onMouseEnter={() => setPlayHover(true)}
|
||||
|
||||
13
src/components/BackButton.tsx
Normal file
13
src/components/BackButton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
export function BackButton() {
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
||||
aria-label='Back'
|
||||
>
|
||||
<ArrowLeft className='w-full h-full' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link as LinkIcon, Search } from 'lucide-react';
|
||||
import { Link as LinkIcon } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useRef, useState } from 'react';
|
||||
@@ -13,7 +13,7 @@ interface DemoCardProps {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function SearchCircle({
|
||||
function PlayCircleSolid({
|
||||
className = '',
|
||||
fillColor = 'none',
|
||||
}: {
|
||||
@@ -37,11 +37,7 @@ function SearchCircle({
|
||||
strokeWidth='1.5'
|
||||
fill={fillColor}
|
||||
/>
|
||||
<foreignObject x='0' y='0' width='44' height='44'>
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
<Search className='h-5 w-5 text-white' strokeWidth={2} />
|
||||
</div>
|
||||
</foreignObject>
|
||||
<polygon points='19,15 19,29 29,22' fill='white' />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +50,9 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(
|
||||
`/aggregate?q=${encodeURIComponent(title.trim())}&type=${type}`
|
||||
`/play?title=${encodeURIComponent(
|
||||
title.trim()
|
||||
)}&douban_id=${id}&type=${type}`
|
||||
);
|
||||
};
|
||||
|
||||
@@ -103,7 +101,7 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
||||
hover ? 'scale-110 rotate-12' : 'scale-90'
|
||||
}`}
|
||||
>
|
||||
<SearchCircle fillColor={hover ? '#22c55e' : 'none'} />
|
||||
<PlayCircleSolid fillColor={hover ? '#22c55e' : 'none'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
389
src/components/EpisodeSelector.tsx
Normal file
389
src/components/EpisodeSelector.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
/* 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;
|
||||
@@ -2,14 +2,27 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { BackButton } from './BackButton';
|
||||
import { LogoutButton } from './LogoutButton';
|
||||
import { useSite } from './SiteProvider';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
const MobileHeader = () => {
|
||||
interface MobileHeaderProps {
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
|
||||
const { siteName } = useSite();
|
||||
return (
|
||||
<header className='md:hidden relative w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'>
|
||||
{/* 返回按钮 */}
|
||||
{showBackButton && (
|
||||
<div className='absolute top-1/2 left-4 -translate-y-1/2'>
|
||||
<BackButton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 站点名称 */}
|
||||
<div className='h-12 flex items-center justify-center'>
|
||||
<Link
|
||||
href='/'
|
||||
@@ -18,6 +31,8 @@ const MobileHeader = () => {
|
||||
{siteName}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 右侧按钮 */}
|
||||
<div className='absolute top-1/2 right-4 -translate-y-1/2 flex items-center gap-2'>
|
||||
<LogoutButton />
|
||||
<ThemeToggle />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BackButton } from './BackButton';
|
||||
import { LogoutButton } from './LogoutButton';
|
||||
import MobileBottomNav from './MobileBottomNav';
|
||||
import MobileHeader from './MobileHeader';
|
||||
import { useSidebar } from './Sidebar';
|
||||
import Sidebar from './Sidebar';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
@@ -11,40 +11,50 @@ interface PageLayoutProps {
|
||||
}
|
||||
|
||||
const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
|
||||
const { isCollapsed } = useSidebar();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 桌面端布局 */}
|
||||
<div className='hidden md:grid md:grid-cols-[auto_1fr] w-full'>
|
||||
<Sidebar activePath={activePath} />
|
||||
<div
|
||||
className={`relative min-w-0 transition-all duration-300 ${
|
||||
isCollapsed ? 'col-start-2' : 'col-start-2'
|
||||
}`}
|
||||
>
|
||||
<div className='w-full min-h-screen'>
|
||||
{/* 移动端头部 */}
|
||||
<MobileHeader showBackButton={['/play'].includes(activePath)} />
|
||||
|
||||
{/* 主要布局容器 */}
|
||||
<div className='flex md:grid md:grid-cols-[auto_1fr] w-full min-h-screen md:min-h-auto'>
|
||||
{/* 侧边栏 - 桌面端显示,移动端隐藏 */}
|
||||
<div className='hidden md:block'>
|
||||
<Sidebar activePath={activePath} />
|
||||
</div>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className='relative min-w-0 flex-1 transition-all duration-300'>
|
||||
{/* 桌面端左上角返回按钮 */}
|
||||
{['/play'].includes(activePath) && (
|
||||
<div className='absolute top-3 left-1 z-20 hidden md:flex'>
|
||||
<BackButton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 桌面端顶部按钮 */}
|
||||
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'>
|
||||
<LogoutButton />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{children}
|
||||
|
||||
{/* 主内容 */}
|
||||
<main
|
||||
className='flex-1 md:min-h-0 mb-14 md:mb-0'
|
||||
style={{
|
||||
paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端布局 */}
|
||||
<div className='md:hidden flex flex-col min-h-screen w-full'>
|
||||
<MobileHeader />
|
||||
<main
|
||||
className='flex-1 mb-14'
|
||||
style={{
|
||||
paddingBottom: 'calc(3.5rem + env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
{/* 移动端底部导航 */}
|
||||
<div className='md:hidden'>
|
||||
<MobileBottomNav activePath={activePath} />
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function VideoCard({
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/detail?source=${source}&id=${id}&title=${encodeURIComponent(
|
||||
href={`/play?source=${source}&id=${id}&title=${encodeURIComponent(
|
||||
title.trim()
|
||||
)}${year ? `&year=${year}` : ''}${from ? `&from=${from}` : ''}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user