Files
LunaTV/src/app/detail/page.tsx
2025-06-23 00:21:27 +08:00

343 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable react-hooks/exhaustive-deps, no-console */
'use client';
import { Heart } 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 { VideoDetail } from '@/lib/types';
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);
// 当接口缺失标题时,使用 URL 中的 title 参数作为后备
const fallbackTitle = searchParams.get('title') || '';
// 格式化剩余时间(如 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 response = await fetch(`/api/detail?source=${source}&id=${id}`);
if (!response.ok) {
throw new Error('获取详情失败');
}
const data = await response.json();
// 如果接口中缺失标题,则补上备用标题
let finalData = data;
if (!data?.videoInfo?.title && fallbackTitle) {
finalData = {
...data,
videoInfo: { ...data.videoInfo, title: fallbackTitle },
};
}
setDetail(finalData);
// 获取播放记录
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.videoInfo.title,
source_name: detail.videoInfo.source_name,
cover: detail.videoInfo.cover || '',
total_episodes: detail.episodes?.length || 1,
save_time: Date.now(),
});
setFavorited(newState);
} catch (err) {
console.error('切换收藏失败:', err);
}
};
return (
<PageLayout activePath='/detail'>
<div className='px-4 sm:px-10 py-4 sm:py-8 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={() => {
const from = searchParams.get('from');
if (from === 'search') {
window.history.back();
} else {
window.location.href = '/';
}
}}
className='absolute top-0 left-0 -translate-x-[60%] -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 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.videoInfo.cover || '/images/placeholder.png'}
alt={detail.videoInfo.title || fallbackTitle}
width={288}
height={432}
className='w-full rounded-xl object-cover'
style={{ aspectRatio: '2/3' }}
priority
/>
</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.videoInfo.title || fallbackTitle}
</h1>
<div className='flex flex-wrap items-center gap-3 text-base mb-4 opacity-80 flex-shrink-0'>
{detail.videoInfo.remarks && (
<span className='text-green-600 font-semibold'>
{detail.videoInfo.remarks}
</span>
)}
{detail.videoInfo.year && (
<span>{detail.videoInfo.year}</span>
)}
{detail.videoInfo.source_name && (
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
{detail.videoInfo.source_name}
</span>
)}
{detail.videoInfo.type && (
<span>{detail.videoInfo.type}</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.videoInfo.title)}`}
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.videoInfo.title
)}`}
className='hidden sm:flex items-center justify-center gap-2 px-6 py-2 bg-gray-500 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.videoInfo.title
)}`}
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'
: 'bg-gray-400 hover:bg-gray-500'
}`}
>
<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 text-xs whitespace-nowrap'>
{playRecord.total_episodes > 1
? `${playRecord.index}集 剩余 `
: '剩余 '}
{formatDuration(
playRecord.total_time - playRecord.play_time
)}
</span>
</div>
)}
{detail.videoInfo.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.videoInfo.desc}
</div>
)}
</div>
</div>
{/* 选集按钮区 */}
{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>
</div>
<div className='flex flex-wrap gap-2 sm:gap-4'>
{detail.episodes.map((episode, idx) => (
<a
key={idx}
href={`/play?source=${searchParams.get(
'source'
)}&id=${searchParams.get('id')}&index=${
idx + 1
}&title=${encodeURIComponent(detail.videoInfo.title)}`}
className='bg-gray-500/80 hover:bg-green-500 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>
);
}