Merge pull request #91 from senshinya/player

全新播放页 + 更换播放器为 Artplayer
This commit is contained in:
senshinya
2025-07-08 00:39:40 +08:00
committed by GitHub
14 changed files with 1370 additions and 2295 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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>
);
})

View File

@@ -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)}

View 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>
);
}

View File

@@ -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>

View 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;

View File

@@ -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 />

View File

@@ -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>
);
};

View File

@@ -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}` : ''}`}
>