From 909aee0d9125ea67d25fb5d91ddac590ff4496f9 Mon Sep 17 00:00:00 2001 From: shinya Date: Thu, 19 Jun 2025 22:27:47 +0800 Subject: [PATCH] feat: refactor detail page && play page support position param --- src/app/api/detail/route.ts | 1 + src/app/detail/page.tsx | 105 +++++++++++++++++++++++----- src/app/play/page.tsx | 79 ++++++++++++++++++++- src/components/ContinueWatching.tsx | 5 ++ src/components/VideoCard.tsx | 40 ++++++++++- 5 files changed, 210 insertions(+), 20 deletions(-) diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 6ce5090..49ae8d5 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -12,6 +12,7 @@ function cleanHtmlTags(text: string): string { .replace(/\n+/g, '\n') // 将多个连续换行合并为一个 .replace(/[ \t]+/g, ' ') // 将多个连续空格和制表符合并为一个空格,但保留换行符 .replace(/^\n+|\n+$/g, '') // 去掉首尾换行 + .replace(/ /g, ' ') // 将   替换为空格 .trim(); // 去掉首尾空格 } diff --git a/src/app/detail/page.tsx b/src/app/detail/page.tsx index 17458de..5997209 100644 --- a/src/app/detail/page.tsx +++ b/src/app/detail/page.tsx @@ -4,6 +4,9 @@ import Image from 'next/image'; import { useSearchParams } from 'next/navigation'; 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 { VideoDetail } from '../api/detail/route'; @@ -13,6 +16,18 @@ export default function DetailPage() { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [playRecord, setPlayRecord] = useState(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(() => { const source = searchParams.get('source'); @@ -24,7 +39,7 @@ export default function DetailPage() { return; } - const fetchDetail = async () => { + const fetchData = async () => { try { const response = await fetch(`/api/detail?source=${source}&id=${id}`); if (!response.ok) { @@ -32,6 +47,11 @@ export default function DetailPage() { } const data = await response.json(); setDetail(data); + + // 获取播放记录 + const allRecords = await getAllPlayRecords(); + const key = generateStorageKey(source, id); + setPlayRecord(allRecords[key] || null); } catch (err) { setError(err instanceof Error ? err.message : '获取详情失败'); } finally { @@ -39,7 +59,7 @@ export default function DetailPage() { } }; - fetchDetail(); + fetchData(); }, [searchParams]); return ( @@ -95,12 +115,12 @@ export default function DetailPage() { {/* 封面 */} -
+
{detail.videoInfo.title}

{detail.videoInfo.title} @@ -130,16 +150,46 @@ export default function DetailPage() { {detail.videoInfo.type} )}

+ {/* 按钮区域 */}
- -
- 播放 -
+ {playRecord ? ( + <> + {/* 恢复播放 */} + +
+ 恢复播放 +
+ {/* 从头开始 */} + +
+ 从头开始 +
+ + ) : ( + <> + {/* 播放 */} + +
+ 播放 +
+ + )} + {/* 爱心按钮 */}
+ {/* 播放记录进度条 */} + {playRecord && ( +
+ {/* 进度条 */} +
+
+
+ {/* 剩余时间 */} + + 剩余{' '} + {formatDuration( + playRecord.total_time - playRecord.play_time + )} + +
+ )} {detail.videoInfo.desc && (
{detail.videoInfo.desc} diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index a2b6116..b83a56f 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -6,7 +6,12 @@ import { useSearchParams } from 'next/navigation'; import { useEffect, useRef, useState } 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'; @@ -82,6 +87,9 @@ export default function PlayPage() { // 总集数:从 detail 中获取,保证随 detail 更新而变化 const totalEpisodes = detail?.episodes?.length || 0; + // 用于记录是否需要在播放器 ready 后跳转到指定进度 + const resumeTimeRef = useRef(null); + // 根据 detail 和集数索引更新视频地址(仅当地址真正变化时) const updateVideoUrl = ( detailData: VideoDetail | null, @@ -162,6 +170,7 @@ export default function PlayPage() { if (searchParams.has('index')) { const newUrl = new URL(window.location.href); newUrl.searchParams.delete('index'); + newUrl.searchParams.delete('position'); window.history.replaceState({}, '', newUrl.toString()); } } catch (err) { @@ -174,6 +183,64 @@ export default function PlayPage() { fetchDetail(); }, [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) => { if (!video) return; @@ -417,6 +484,16 @@ export default function PlayPage() { artPlayerRef.current.on('ready', () => { console.log('播放器准备就绪'); 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) => { diff --git a/src/components/ContinueWatching.tsx b/src/components/ContinueWatching.tsx index 09f4e85..98ed51f 100644 --- a/src/components/ContinueWatching.tsx +++ b/src/components/ContinueWatching.tsx @@ -100,6 +100,11 @@ export default function ContinueWatching({ className }: ContinueWatchingProps) { progress={getProgress(record)} episodes={record.total_episodes} currentEpisode={record.index} + onDelete={() => + setPlayRecords((prev) => + prev.filter((r) => r.key !== record.key) + ) + } />
); diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 86d6b4d..615a50b 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -4,6 +4,8 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import React, { useState } from 'react'; +import { deletePlayRecord } from '@/lib/db.client'; + interface VideoCardProps { id: string; source: string; @@ -14,6 +16,7 @@ interface VideoCardProps { progress?: number; from?: string; currentEpisode?: number; + onDelete?: () => void; } function CheckCircleCustom() { @@ -78,11 +81,34 @@ export default function VideoCard({ progress, from, currentEpisode, + onDelete, }: VideoCardProps) { const [playHover, setPlayHover] = useState(false); + const [deleted, setDeleted] = useState(false); const router = useRouter(); - return ( + // 删除对应播放记录 + const handleDeleteRecord = async ( + e: React.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 : ( @@ -111,8 +137,16 @@ export default function VideoCard({
- - + + + + + +