mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-26 22:24:42 +08:00
feat: save play record using localstorage
This commit is contained in:
72
src/app/api/playrecords/route.ts
Normal file
72
src/app/api/playrecords/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { PlayRecord } from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const records = await db.getAllPlayRecords();
|
||||
return NextResponse.json(records, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { key, record }: { key: string; record: PlayRecord } = body;
|
||||
|
||||
if (!key || !record) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing key or record' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证播放记录数据
|
||||
if (!record.title || !record.source_name || record.index < 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid record data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从key中解析source和id
|
||||
const [source, id] = key.split('+');
|
||||
if (!source || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 保存播放记录(不包含user_id,将由savePlayRecord自动添加)
|
||||
const recordWithoutUserId = {
|
||||
title: record.title,
|
||||
source_name: record.source_name,
|
||||
cover: record.cover,
|
||||
index: record.index,
|
||||
total_episodes: record.total_episodes,
|
||||
play_time: record.play_time,
|
||||
total_time: record.total_time,
|
||||
save_time: record.save_time,
|
||||
};
|
||||
|
||||
await db.savePlayRecord(source, id, recordWithoutUserId);
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('保存播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,10 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import CollectionCard from '@/components/CollectionCard';
|
||||
import ContinueWatching from '@/components/ContinueWatching';
|
||||
import DemoCard from '@/components/DemoCard';
|
||||
import PageLayout from '@/components/layout/PageLayout';
|
||||
import ScrollableRow from '@/components/ScrollableRow';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
const defaultPoster =
|
||||
'https://vip.dytt-img.com/upload/vod/20250326-1/9857e2e8581f231e24747ee32e633a3b.jpg';
|
||||
|
||||
interface DoubanItem {
|
||||
title: string;
|
||||
@@ -32,76 +29,6 @@ interface DoubanResponse {
|
||||
list: DoubanItem[];
|
||||
}
|
||||
|
||||
// 模拟数据
|
||||
const mockData = {
|
||||
recentMovies: [
|
||||
{
|
||||
id: '1',
|
||||
title: '流浪地球2',
|
||||
poster: defaultPoster,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '满江红',
|
||||
poster: defaultPoster,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
],
|
||||
recentTvShows: [
|
||||
{
|
||||
id: '3',
|
||||
title: '三体',
|
||||
poster: defaultPoster,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
episodes: 30,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: '狂飙',
|
||||
poster: defaultPoster,
|
||||
episodes: 39,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: '332',
|
||||
title: '三体',
|
||||
poster: defaultPoster,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
episodes: 30,
|
||||
},
|
||||
{
|
||||
id: '4231',
|
||||
title: '狂飙',
|
||||
poster: defaultPoster,
|
||||
episodes: 39,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
{
|
||||
id: '3342',
|
||||
title: '三体',
|
||||
poster: defaultPoster,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
episodes: 30,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: '狂飙',
|
||||
poster: defaultPoster,
|
||||
episodes: 39,
|
||||
source: 'dyttzy',
|
||||
source_name: '电影天堂',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 合集数据
|
||||
const collections = [
|
||||
{
|
||||
@@ -194,20 +121,7 @@ export default function Home() {
|
||||
</section>
|
||||
|
||||
{/* 继续观看 */}
|
||||
<section className='mb-8'>
|
||||
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||
继续观看
|
||||
</h2>
|
||||
<ScrollableRow>
|
||||
{[...mockData.recentMovies, ...mockData.recentTvShows].map(
|
||||
(item) => (
|
||||
<div key={item.id} className='min-w-[180px] w-44'>
|
||||
<VideoCard {...item} progress={Math.random() * 100} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ScrollableRow>
|
||||
</section>
|
||||
<ContinueWatching />
|
||||
|
||||
{/* 热门电影 */}
|
||||
<section className='mb-8'>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { deletePlayRecord, savePlayRecord } from '@/lib/db.client';
|
||||
|
||||
import { VideoDetail } from '../api/detail/route';
|
||||
|
||||
// 动态导入 Artplayer 和 Hls 以避免 SSR 问题
|
||||
@@ -69,6 +71,14 @@ export default function PlayPage() {
|
||||
// 视频播放地址
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
|
||||
// 播放进度保存相关
|
||||
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSaveTimeRef = useRef<number>(0);
|
||||
const videoEventListenersRef = useRef<{
|
||||
video: HTMLVideoElement;
|
||||
listeners: Array<{ event: string; handler: EventListener }>;
|
||||
} | null>(null);
|
||||
|
||||
// 总集数:从 detail 中获取,保证随 detail 更新而变化
|
||||
const totalEpisodes = detail?.episodes?.length || 0;
|
||||
|
||||
@@ -142,6 +152,8 @@ export default function PlayPage() {
|
||||
setVideoTitle(data.videoInfo.title);
|
||||
setVideoCover(data.videoInfo.cover);
|
||||
setDetail(data);
|
||||
|
||||
// 确保集数索引在有效范围内
|
||||
if (currentEpisodeIndex >= data.episodes.length) {
|
||||
setCurrentEpisodeIndex(0);
|
||||
}
|
||||
@@ -162,6 +174,45 @@ export default function PlayPage() {
|
||||
fetchDetail();
|
||||
}, [currentSource]);
|
||||
|
||||
const attachVideoEventListeners = (video: HTMLVideoElement) => {
|
||||
if (!video) return;
|
||||
|
||||
// 移除旧监听器(如果存在)
|
||||
if (videoEventListenersRef.current) {
|
||||
const { video: oldVideo, listeners } = videoEventListenersRef.current;
|
||||
listeners.forEach(({ event, handler }) => {
|
||||
oldVideo.removeEventListener(event, handler);
|
||||
});
|
||||
videoEventListenersRef.current = null;
|
||||
}
|
||||
|
||||
// 暂停时立即保存
|
||||
const pauseHandler = () => {
|
||||
saveCurrentPlayProgress();
|
||||
};
|
||||
|
||||
// timeupdate 节流(5 秒)保存
|
||||
let lastSave = 0;
|
||||
const timeUpdateHandler = () => {
|
||||
const now = Date.now();
|
||||
if (now - lastSave > 5000) {
|
||||
saveCurrentPlayProgress();
|
||||
lastSave = now;
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('pause', pauseHandler);
|
||||
video.addEventListener('timeupdate', timeUpdateHandler);
|
||||
|
||||
videoEventListenersRef.current = {
|
||||
video,
|
||||
listeners: [
|
||||
{ event: 'pause', handler: pauseHandler },
|
||||
{ event: 'timeupdate', handler: timeUpdateHandler },
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
// 播放器创建/切换逻辑,只依赖视频URL和集数索引
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -189,6 +240,7 @@ export default function PlayPage() {
|
||||
setError('视频地址无效');
|
||||
return;
|
||||
}
|
||||
console.log(videoUrl);
|
||||
|
||||
// 检测是否为WebKit浏览器
|
||||
const isWebkit =
|
||||
@@ -202,7 +254,11 @@ export default function PlayPage() {
|
||||
currentEpisodeIndex + 1
|
||||
}集`;
|
||||
artPlayerRef.current.poster = videoCover;
|
||||
console.log(videoUrl);
|
||||
if (artPlayerRef.current?.video) {
|
||||
attachVideoEventListeners(
|
||||
artPlayerRef.current.video as HTMLVideoElement
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -380,11 +436,41 @@ export default function PlayPage() {
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
if (artPlayerRef.current?.video) {
|
||||
attachVideoEventListeners(
|
||||
artPlayerRef.current.video as HTMLVideoElement
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建播放器失败:', err);
|
||||
setError('播放器初始化失败');
|
||||
}
|
||||
}, [videoUrl]);
|
||||
}, [Artplayer, Hls, videoUrl]);
|
||||
|
||||
// 页面卸载和隐藏时保存播放进度
|
||||
useEffect(() => {
|
||||
// 页面即将卸载时保存播放进度
|
||||
const handleBeforeUnload = () => {
|
||||
saveCurrentPlayProgress();
|
||||
};
|
||||
|
||||
// 页面可见性变化时保存播放进度
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
saveCurrentPlayProgress();
|
||||
}
|
||||
};
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
// 清理事件监听器
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [currentEpisodeIndex, detail, artPlayerRef.current]);
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
@@ -395,6 +481,18 @@ export default function PlayPage() {
|
||||
if (shortcutHintTimeoutRef.current) {
|
||||
clearTimeout(shortcutHintTimeoutRef.current);
|
||||
}
|
||||
if (saveIntervalRef.current) {
|
||||
clearInterval(saveIntervalRef.current);
|
||||
}
|
||||
|
||||
// 清理视频事件监听器
|
||||
if (videoEventListenersRef.current) {
|
||||
const { video, listeners } = videoEventListenersRef.current;
|
||||
listeners.forEach(({ event, handler }) => {
|
||||
video.removeEventListener(event, handler);
|
||||
});
|
||||
videoEventListenersRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -418,6 +516,14 @@ export default function PlayPage() {
|
||||
// 处理选集切换
|
||||
const handleEpisodeChange = (episodeIndex: number) => {
|
||||
if (episodeIndex >= 0 && episodeIndex < totalEpisodes) {
|
||||
// 在更换集数前保存当前播放进度
|
||||
if (
|
||||
artPlayerRef.current &&
|
||||
artPlayerRef.current.video &&
|
||||
!artPlayerRef.current.video.paused
|
||||
) {
|
||||
saveCurrentPlayProgress();
|
||||
}
|
||||
setCurrentEpisodeIndex(episodeIndex);
|
||||
setShowEpisodePanel(false);
|
||||
}
|
||||
@@ -430,6 +536,14 @@ export default function PlayPage() {
|
||||
detail.episodes &&
|
||||
currentEpisodeIndex < detail.episodes.length - 1
|
||||
) {
|
||||
// 在更换集数前保存当前播放进度
|
||||
if (
|
||||
artPlayerRef.current &&
|
||||
artPlayerRef.current.video &&
|
||||
!artPlayerRef.current.video.paused
|
||||
) {
|
||||
saveCurrentPlayProgress();
|
||||
}
|
||||
setCurrentEpisodeIndex(currentEpisodeIndex + 1);
|
||||
}
|
||||
};
|
||||
@@ -478,6 +592,13 @@ export default function PlayPage() {
|
||||
// 处理上一集
|
||||
const handlePreviousEpisode = () => {
|
||||
if (detail && currentEpisodeIndex > 0) {
|
||||
if (
|
||||
artPlayerRef.current &&
|
||||
artPlayerRef.current.video &&
|
||||
!artPlayerRef.current.video.paused
|
||||
) {
|
||||
saveCurrentPlayProgress();
|
||||
}
|
||||
setCurrentEpisodeIndex(currentEpisodeIndex - 1);
|
||||
}
|
||||
};
|
||||
@@ -539,13 +660,23 @@ export default function PlayPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理换源 - 使用 startTransition 批量更新状态
|
||||
// 处理换源
|
||||
const handleSourceChange = async (newSource: string, newId: string) => {
|
||||
try {
|
||||
// 显示换源加载状态
|
||||
setSourceChanging(true);
|
||||
setError(null);
|
||||
|
||||
// 清除前一个历史记录
|
||||
if (currentSource && currentId) {
|
||||
try {
|
||||
await deletePlayRecord(currentSource, currentId);
|
||||
console.log('已清除前一个播放记录');
|
||||
} catch (err) {
|
||||
console.error('清除播放记录失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取新源的详情
|
||||
const response = await fetch(
|
||||
`/api/detail?source=${newSource}&id=${newId}`
|
||||
@@ -715,6 +846,50 @@ export default function PlayPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// 保存播放进度的函数
|
||||
const saveCurrentPlayProgress = async () => {
|
||||
if (
|
||||
!artPlayerRef.current?.video ||
|
||||
!currentSource ||
|
||||
!currentId ||
|
||||
!videoTitle ||
|
||||
!detail?.videoInfo?.source_name
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const video = artPlayerRef.current.video;
|
||||
const currentTime = video.currentTime || 0;
|
||||
const duration = video.duration || 0;
|
||||
|
||||
// 如果播放时间太短(少于5秒)或者视频时长无效,不保存
|
||||
if (currentTime < 1 || !duration) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await savePlayRecord(currentSource, currentId, {
|
||||
title: videoTitle,
|
||||
source_name: detail.videoInfo.source_name,
|
||||
cover: videoCover,
|
||||
index: currentEpisodeIndex + 1, // 转换为1基索引
|
||||
total_episodes: totalEpisodes,
|
||||
play_time: Math.floor(currentTime),
|
||||
total_time: Math.floor(duration),
|
||||
save_time: Date.now(),
|
||||
});
|
||||
|
||||
lastSaveTimeRef.current = Date.now();
|
||||
console.log('播放进度已保存:', {
|
||||
title: videoTitle,
|
||||
episode: currentEpisodeIndex + 1,
|
||||
progress: `${Math.floor(currentTime)}/${Math.floor(duration)}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('保存播放进度失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='min-h-screen bg-black flex items-center justify-center'>
|
||||
|
||||
Reference in New Issue
Block a user