mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-06-11 11:33:10 +08:00
feat: refactor detail page && play page support position param
This commit is contained in:
@@ -12,6 +12,7 @@ function cleanHtmlTags(text: string): string {
|
|||||||
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
|
.replace(/\n+/g, '\n') // 将多个连续换行合并为一个
|
||||||
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
|
.replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符
|
||||||
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
|
.replace(/^\n+|\n+$/g, '') // 去掉首尾换行
|
||||||
|
.replace(/ /g, ' ') // 将 替换为空格
|
||||||
.trim(); // 去掉首尾空格
|
.trim(); // 去掉首尾空格
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import Image from 'next/image';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type { PlayRecord } from '@/lib/db.client';
|
||||||
|
import { generateStorageKey, getAllPlayRecords } from '@/lib/db.client';
|
||||||
|
|
||||||
import PageLayout from '@/components/layout/PageLayout';
|
import PageLayout from '@/components/layout/PageLayout';
|
||||||
|
|
||||||
import { VideoDetail } from '../api/detail/route';
|
import { VideoDetail } from '../api/detail/route';
|
||||||
@@ -13,6 +16,18 @@ export default function DetailPage() {
|
|||||||
const [detail, setDetail] = useState<VideoDetail | null>(null);
|
const [detail, setDetail] = useState<VideoDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [playRecord, setPlayRecord] = useState<PlayRecord | null>(null);
|
||||||
|
|
||||||
|
// 格式化剩余时间(如 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(() => {
|
useEffect(() => {
|
||||||
const source = searchParams.get('source');
|
const source = searchParams.get('source');
|
||||||
@@ -24,7 +39,7 @@ export default function DetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchDetail = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/detail?source=${source}&id=${id}`);
|
const response = await fetch(`/api/detail?source=${source}&id=${id}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -32,6 +47,11 @@ export default function DetailPage() {
|
|||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setDetail(data);
|
setDetail(data);
|
||||||
|
|
||||||
|
// 获取播放记录
|
||||||
|
const allRecords = await getAllPlayRecords();
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
setPlayRecord(allRecords[key] || null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '获取详情失败');
|
setError(err instanceof Error ? err.message : '获取详情失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -39,7 +59,7 @@ export default function DetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchDetail();
|
fetchData();
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,12 +115,12 @@ export default function DetailPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/* 封面 */}
|
{/* 封面 */}
|
||||||
<div className='flex-shrink-0 w-full md:w-64'>
|
<div className='flex-shrink-0 w-full md:w-72'>
|
||||||
<Image
|
<Image
|
||||||
src={detail.videoInfo.cover || '/images/placeholder.png'}
|
src={detail.videoInfo.cover || '/images/placeholder.png'}
|
||||||
alt={detail.videoInfo.title}
|
alt={detail.videoInfo.title}
|
||||||
width={256}
|
width={288}
|
||||||
height={384}
|
height={432}
|
||||||
className='w-full rounded-xl object-cover'
|
className='w-full rounded-xl object-cover'
|
||||||
style={{ aspectRatio: '2/3' }}
|
style={{ aspectRatio: '2/3' }}
|
||||||
priority
|
priority
|
||||||
@@ -109,7 +129,7 @@ export default function DetailPage() {
|
|||||||
{/* 右侧信息 */}
|
{/* 右侧信息 */}
|
||||||
<div
|
<div
|
||||||
className='flex-1 flex flex-col min-h-0'
|
className='flex-1 flex flex-col min-h-0'
|
||||||
style={{ height: '384px' }}
|
style={{ height: '430px' }}
|
||||||
>
|
>
|
||||||
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0'>
|
<h1 className='text-3xl font-bold mb-2 tracking-wide flex items-center flex-shrink-0'>
|
||||||
{detail.videoInfo.title}
|
{detail.videoInfo.title}
|
||||||
@@ -130,16 +150,46 @@ export default function DetailPage() {
|
|||||||
<span>{detail.videoInfo.type}</span>
|
<span>{detail.videoInfo.type}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 按钮区域 */}
|
||||||
<div className='flex items-center gap-4 mb-4 flex-shrink-0'>
|
<div className='flex items-center gap-4 mb-4 flex-shrink-0'>
|
||||||
<a
|
{playRecord ? (
|
||||||
href={`/play?source=${searchParams.get(
|
<>
|
||||||
'source'
|
{/* 恢复播放 */}
|
||||||
)}&id=${searchParams.get('id')}`}
|
<a
|
||||||
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'
|
href={`/play?source=${searchParams.get(
|
||||||
>
|
'source'
|
||||||
<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>
|
)}&id=${searchParams.get('id')}`}
|
||||||
<span>播放</span>
|
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'
|
||||||
</a>
|
>
|
||||||
|
<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`}
|
||||||
|
className='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`}
|
||||||
|
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 className='flex items-center justify-center w-10 h-10 bg-pink-400 hover:bg-pink-500 rounded-full transition-colors'>
|
<button className='flex items-center justify-center w-10 h-10 bg-pink-400 hover:bg-pink-500 rounded-full transition-colors'>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
@@ -157,9 +207,32 @@ export default function DetailPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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'>
|
||||||
|
剩余{' '}
|
||||||
|
{formatDuration(
|
||||||
|
playRecord.total_time - playRecord.play_time
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{detail.videoInfo.desc && (
|
{detail.videoInfo.desc && (
|
||||||
<div
|
<div
|
||||||
className='mt-4 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide'
|
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' }}
|
style={{ whiteSpace: 'pre-line' }}
|
||||||
>
|
>
|
||||||
{detail.videoInfo.desc}
|
{detail.videoInfo.desc}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { useSearchParams } from 'next/navigation';
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { deletePlayRecord, savePlayRecord } from '@/lib/db.client';
|
import {
|
||||||
|
deletePlayRecord,
|
||||||
|
generateStorageKey,
|
||||||
|
getAllPlayRecords,
|
||||||
|
savePlayRecord,
|
||||||
|
} from '@/lib/db.client';
|
||||||
|
|
||||||
import { VideoDetail } from '../api/detail/route';
|
import { VideoDetail } from '../api/detail/route';
|
||||||
|
|
||||||
@@ -82,6 +87,9 @@ export default function PlayPage() {
|
|||||||
// 总集数:从 detail 中获取,保证随 detail 更新而变化
|
// 总集数:从 detail 中获取,保证随 detail 更新而变化
|
||||||
const totalEpisodes = detail?.episodes?.length || 0;
|
const totalEpisodes = detail?.episodes?.length || 0;
|
||||||
|
|
||||||
|
// 用于记录是否需要在播放器 ready 后跳转到指定进度
|
||||||
|
const resumeTimeRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// 根据 detail 和集数索引更新视频地址(仅当地址真正变化时)
|
// 根据 detail 和集数索引更新视频地址(仅当地址真正变化时)
|
||||||
const updateVideoUrl = (
|
const updateVideoUrl = (
|
||||||
detailData: VideoDetail | null,
|
detailData: VideoDetail | null,
|
||||||
@@ -162,6 +170,7 @@ export default function PlayPage() {
|
|||||||
if (searchParams.has('index')) {
|
if (searchParams.has('index')) {
|
||||||
const newUrl = new URL(window.location.href);
|
const newUrl = new URL(window.location.href);
|
||||||
newUrl.searchParams.delete('index');
|
newUrl.searchParams.delete('index');
|
||||||
|
newUrl.searchParams.delete('position');
|
||||||
window.history.replaceState({}, '', newUrl.toString());
|
window.history.replaceState({}, '', newUrl.toString());
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -174,6 +183,64 @@ export default function PlayPage() {
|
|||||||
fetchDetail();
|
fetchDetail();
|
||||||
}, [currentSource]);
|
}, [currentSource]);
|
||||||
|
|
||||||
|
/* -------------------- 播放记录处理 -------------------- */
|
||||||
|
useEffect(() => {
|
||||||
|
// 仅在初次挂载时检查播放记录
|
||||||
|
const initFromHistory = async () => {
|
||||||
|
if (!currentSource || !currentId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allRecords = await getAllPlayRecords();
|
||||||
|
const key = generateStorageKey(currentSource, currentId);
|
||||||
|
const record = allRecords[key];
|
||||||
|
|
||||||
|
// URL 参数
|
||||||
|
const urlIndexParam = searchParams.get('index');
|
||||||
|
const urlPositionParam = searchParams.get('position');
|
||||||
|
|
||||||
|
// 当index参数存在时的处理逻辑
|
||||||
|
if (urlIndexParam) {
|
||||||
|
const urlIndex = parseInt(urlIndexParam, 10) - 1;
|
||||||
|
let targetTime = 0; // 默认从0开始
|
||||||
|
|
||||||
|
// 只有index参数和position参数都存在时才生效position
|
||||||
|
if (urlPositionParam) {
|
||||||
|
targetTime = parseInt(urlPositionParam, 10);
|
||||||
|
} else if (record && urlIndex === record.index - 1) {
|
||||||
|
// 如果有同集播放记录则跳转到播放记录处
|
||||||
|
targetTime = record.play_time;
|
||||||
|
}
|
||||||
|
// 否则从0开始(targetTime已经是0)
|
||||||
|
|
||||||
|
// 更新当前选集索引
|
||||||
|
if (urlIndex !== currentEpisodeIndex) {
|
||||||
|
setCurrentEpisodeIndex(urlIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存待恢复的播放进度,待播放器就绪后跳转
|
||||||
|
resumeTimeRef.current = targetTime;
|
||||||
|
} else if (record) {
|
||||||
|
// 没有index参数但有播放记录时,使用原有逻辑
|
||||||
|
const targetIndex = record.index - 1;
|
||||||
|
const targetTime = record.play_time;
|
||||||
|
|
||||||
|
// 更新当前选集索引
|
||||||
|
if (targetIndex !== currentEpisodeIndex) {
|
||||||
|
setCurrentEpisodeIndex(targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存待恢复的播放进度,待播放器就绪后跳转
|
||||||
|
resumeTimeRef.current = targetTime;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('读取播放记录失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initFromHistory();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
const attachVideoEventListeners = (video: HTMLVideoElement) => {
|
const attachVideoEventListeners = (video: HTMLVideoElement) => {
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
@@ -417,6 +484,16 @@ export default function PlayPage() {
|
|||||||
artPlayerRef.current.on('ready', () => {
|
artPlayerRef.current.on('ready', () => {
|
||||||
console.log('播放器准备就绪');
|
console.log('播放器准备就绪');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// 若存在需要恢复的播放进度,则跳转
|
||||||
|
if (resumeTimeRef.current && resumeTimeRef.current > 0) {
|
||||||
|
try {
|
||||||
|
artPlayerRef.current.video.currentTime = resumeTimeRef.current;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('恢复播放进度失败:', err);
|
||||||
|
}
|
||||||
|
resumeTimeRef.current = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
artPlayerRef.current.on('error', (err: any) => {
|
artPlayerRef.current.on('error', (err: any) => {
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
|||||||
progress={getProgress(record)}
|
progress={getProgress(record)}
|
||||||
episodes={record.total_episodes}
|
episodes={record.total_episodes}
|
||||||
currentEpisode={record.index}
|
currentEpisode={record.index}
|
||||||
|
onDelete={() =>
|
||||||
|
setPlayRecords((prev) =>
|
||||||
|
prev.filter((r) => r.key !== record.key)
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import Link from 'next/link';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { deletePlayRecord } from '@/lib/db.client';
|
||||||
|
|
||||||
interface VideoCardProps {
|
interface VideoCardProps {
|
||||||
id: string;
|
id: string;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -14,6 +16,7 @@ interface VideoCardProps {
|
|||||||
progress?: number;
|
progress?: number;
|
||||||
from?: string;
|
from?: string;
|
||||||
currentEpisode?: number;
|
currentEpisode?: number;
|
||||||
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckCircleCustom() {
|
function CheckCircleCustom() {
|
||||||
@@ -78,11 +81,34 @@ export default function VideoCard({
|
|||||||
progress,
|
progress,
|
||||||
from,
|
from,
|
||||||
currentEpisode,
|
currentEpisode,
|
||||||
|
onDelete,
|
||||||
}: VideoCardProps) {
|
}: VideoCardProps) {
|
||||||
const [playHover, setPlayHover] = useState(false);
|
const [playHover, setPlayHover] = useState(false);
|
||||||
|
const [deleted, setDeleted] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
// 删除对应播放记录
|
||||||
|
const handleDeleteRecord = async (
|
||||||
|
e: React.MouseEvent<HTMLSpanElement | SVGElement, MouseEvent>
|
||||||
|
) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deletePlayRecord(source, id);
|
||||||
|
|
||||||
|
// 通知父组件更新
|
||||||
|
onDelete?.();
|
||||||
|
|
||||||
|
// 若父组件未处理,可本地隐藏
|
||||||
|
setDeleted(true);
|
||||||
|
} catch (err) {
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
console.error('删除播放记录失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return deleted ? null : (
|
||||||
<Link
|
<Link
|
||||||
href={`/detail?source=${source}&id=${id}${from ? `&from=${from}` : ''}`}
|
href={`/detail?source=${source}&id=${id}${from ? `&from=${from}` : ''}`}
|
||||||
>
|
>
|
||||||
@@ -111,8 +137,16 @@ export default function VideoCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='absolute bottom-4 right-4 flex items-center gap-6'>
|
<div className='absolute bottom-4 right-4 flex items-center gap-6'>
|
||||||
<CheckCircleCustom />
|
<span
|
||||||
<Heart className='h-5 w-5 text-white/90 stroke-[2]' />
|
onClick={handleDeleteRecord}
|
||||||
|
title='标记已看'
|
||||||
|
className='inline-flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<CheckCircleCustom />
|
||||||
|
</span>
|
||||||
|
<span className='inline-flex items-center justify-center'>
|
||||||
|
<Heart className='h-6 w-6 text-white/90 stroke-[2]' />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user