mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-05 19:17:30 +08:00
feat: save play record using localstorage
This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"cache_time": 7200,
|
"cache_time": 7200,
|
||||||
"api_site": {}
|
"api_site": {},
|
||||||
|
"storage": {
|
||||||
|
"type": "localstorage"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
dirs: ['src'],
|
dirs: ['src'],
|
||||||
@@ -7,6 +8,28 @@ const nextConfig = {
|
|||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
swcMinify: true,
|
swcMinify: true,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在编译阶段把 storage.type 写入环境变量,供浏览器端动态切换存储方案。
|
||||||
|
*/
|
||||||
|
env: (function () {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let storageType = 'localstorage';
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(__dirname, 'config.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
storageType = json?.storage?.type ?? 'localstorage';
|
||||||
|
} catch {
|
||||||
|
// ignore – 保持默认值
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
NEXT_PUBLIC_STORAGE_TYPE: storageType,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
|
||||||
// Uncoment to add domain whitelist
|
// Uncoment to add domain whitelist
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
|
|||||||
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 CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||||
import CollectionCard from '@/components/CollectionCard';
|
import CollectionCard from '@/components/CollectionCard';
|
||||||
|
import ContinueWatching from '@/components/ContinueWatching';
|
||||||
import DemoCard from '@/components/DemoCard';
|
import DemoCard from '@/components/DemoCard';
|
||||||
import PageLayout from '@/components/layout/PageLayout';
|
import PageLayout from '@/components/layout/PageLayout';
|
||||||
import ScrollableRow from '@/components/ScrollableRow';
|
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 {
|
interface DoubanItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -32,76 +29,6 @@ interface DoubanResponse {
|
|||||||
list: DoubanItem[];
|
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 = [
|
const collections = [
|
||||||
{
|
{
|
||||||
@@ -194,20 +121,7 @@ export default function Home() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 继续观看 */}
|
{/* 继续观看 */}
|
||||||
<section className='mb-8'>
|
<ContinueWatching />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 热门电影 */}
|
{/* 热门电影 */}
|
||||||
<section className='mb-8'>
|
<section className='mb-8'>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ 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 { VideoDetail } from '../api/detail/route';
|
import { VideoDetail } from '../api/detail/route';
|
||||||
|
|
||||||
// 动态导入 Artplayer 和 Hls 以避免 SSR 问题
|
// 动态导入 Artplayer 和 Hls 以避免 SSR 问题
|
||||||
@@ -69,6 +71,14 @@ export default function PlayPage() {
|
|||||||
// 视频播放地址
|
// 视频播放地址
|
||||||
const [videoUrl, setVideoUrl] = useState('');
|
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 更新而变化
|
// 总集数:从 detail 中获取,保证随 detail 更新而变化
|
||||||
const totalEpisodes = detail?.episodes?.length || 0;
|
const totalEpisodes = detail?.episodes?.length || 0;
|
||||||
|
|
||||||
@@ -142,6 +152,8 @@ export default function PlayPage() {
|
|||||||
setVideoTitle(data.videoInfo.title);
|
setVideoTitle(data.videoInfo.title);
|
||||||
setVideoCover(data.videoInfo.cover);
|
setVideoCover(data.videoInfo.cover);
|
||||||
setDetail(data);
|
setDetail(data);
|
||||||
|
|
||||||
|
// 确保集数索引在有效范围内
|
||||||
if (currentEpisodeIndex >= data.episodes.length) {
|
if (currentEpisodeIndex >= data.episodes.length) {
|
||||||
setCurrentEpisodeIndex(0);
|
setCurrentEpisodeIndex(0);
|
||||||
}
|
}
|
||||||
@@ -162,6 +174,45 @@ export default function PlayPage() {
|
|||||||
fetchDetail();
|
fetchDetail();
|
||||||
}, [currentSource]);
|
}, [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和集数索引
|
// 播放器创建/切换逻辑,只依赖视频URL和集数索引
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -189,6 +240,7 @@ export default function PlayPage() {
|
|||||||
setError('视频地址无效');
|
setError('视频地址无效');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log(videoUrl);
|
||||||
|
|
||||||
// 检测是否为WebKit浏览器
|
// 检测是否为WebKit浏览器
|
||||||
const isWebkit =
|
const isWebkit =
|
||||||
@@ -202,7 +254,11 @@ export default function PlayPage() {
|
|||||||
currentEpisodeIndex + 1
|
currentEpisodeIndex + 1
|
||||||
}集`;
|
}集`;
|
||||||
artPlayerRef.current.poster = videoCover;
|
artPlayerRef.current.poster = videoCover;
|
||||||
console.log(videoUrl);
|
if (artPlayerRef.current?.video) {
|
||||||
|
attachVideoEventListeners(
|
||||||
|
artPlayerRef.current.video as HTMLVideoElement
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,11 +436,41 @@ export default function PlayPage() {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (artPlayerRef.current?.video) {
|
||||||
|
attachVideoEventListeners(
|
||||||
|
artPlayerRef.current.video as HTMLVideoElement
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('创建播放器失败:', err);
|
console.error('创建播放器失败:', err);
|
||||||
setError('播放器初始化失败');
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -395,6 +481,18 @@ export default function PlayPage() {
|
|||||||
if (shortcutHintTimeoutRef.current) {
|
if (shortcutHintTimeoutRef.current) {
|
||||||
clearTimeout(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) => {
|
const handleEpisodeChange = (episodeIndex: number) => {
|
||||||
if (episodeIndex >= 0 && episodeIndex < totalEpisodes) {
|
if (episodeIndex >= 0 && episodeIndex < totalEpisodes) {
|
||||||
|
// 在更换集数前保存当前播放进度
|
||||||
|
if (
|
||||||
|
artPlayerRef.current &&
|
||||||
|
artPlayerRef.current.video &&
|
||||||
|
!artPlayerRef.current.video.paused
|
||||||
|
) {
|
||||||
|
saveCurrentPlayProgress();
|
||||||
|
}
|
||||||
setCurrentEpisodeIndex(episodeIndex);
|
setCurrentEpisodeIndex(episodeIndex);
|
||||||
setShowEpisodePanel(false);
|
setShowEpisodePanel(false);
|
||||||
}
|
}
|
||||||
@@ -430,6 +536,14 @@ export default function PlayPage() {
|
|||||||
detail.episodes &&
|
detail.episodes &&
|
||||||
currentEpisodeIndex < detail.episodes.length - 1
|
currentEpisodeIndex < detail.episodes.length - 1
|
||||||
) {
|
) {
|
||||||
|
// 在更换集数前保存当前播放进度
|
||||||
|
if (
|
||||||
|
artPlayerRef.current &&
|
||||||
|
artPlayerRef.current.video &&
|
||||||
|
!artPlayerRef.current.video.paused
|
||||||
|
) {
|
||||||
|
saveCurrentPlayProgress();
|
||||||
|
}
|
||||||
setCurrentEpisodeIndex(currentEpisodeIndex + 1);
|
setCurrentEpisodeIndex(currentEpisodeIndex + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -478,6 +592,13 @@ export default function PlayPage() {
|
|||||||
// 处理上一集
|
// 处理上一集
|
||||||
const handlePreviousEpisode = () => {
|
const handlePreviousEpisode = () => {
|
||||||
if (detail && currentEpisodeIndex > 0) {
|
if (detail && currentEpisodeIndex > 0) {
|
||||||
|
if (
|
||||||
|
artPlayerRef.current &&
|
||||||
|
artPlayerRef.current.video &&
|
||||||
|
!artPlayerRef.current.video.paused
|
||||||
|
) {
|
||||||
|
saveCurrentPlayProgress();
|
||||||
|
}
|
||||||
setCurrentEpisodeIndex(currentEpisodeIndex - 1);
|
setCurrentEpisodeIndex(currentEpisodeIndex - 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -539,13 +660,23 @@ export default function PlayPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理换源 - 使用 startTransition 批量更新状态
|
// 处理换源
|
||||||
const handleSourceChange = async (newSource: string, newId: string) => {
|
const handleSourceChange = async (newSource: string, newId: string) => {
|
||||||
try {
|
try {
|
||||||
// 显示换源加载状态
|
// 显示换源加载状态
|
||||||
setSourceChanging(true);
|
setSourceChanging(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// 清除前一个历史记录
|
||||||
|
if (currentSource && currentId) {
|
||||||
|
try {
|
||||||
|
await deletePlayRecord(currentSource, currentId);
|
||||||
|
console.log('已清除前一个播放记录');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('清除播放记录失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取新源的详情
|
// 获取新源的详情
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/api/detail?source=${newSource}&id=${newId}`
|
`/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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className='min-h-screen bg-black flex items-center justify-center'>
|
<div className='min-h-screen bg-black flex items-center justify-center'>
|
||||||
|
|||||||
110
src/components/ContinueWatching.tsx
Normal file
110
src/components/ContinueWatching.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import type { PlayRecord } from '@/lib/db.client';
|
||||||
|
import { getAllPlayRecords } from '@/lib/db.client';
|
||||||
|
|
||||||
|
import ScrollableRow from '@/components/ScrollableRow';
|
||||||
|
import VideoCard from '@/components/VideoCard';
|
||||||
|
|
||||||
|
interface ContinueWatchingProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContinueWatching({ className }: ContinueWatchingProps) {
|
||||||
|
const [playRecords, setPlayRecords] = useState<
|
||||||
|
(PlayRecord & { key: string })[]
|
||||||
|
>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlayRecords = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 从 localStorage 获取所有播放记录
|
||||||
|
const allRecords = await getAllPlayRecords();
|
||||||
|
|
||||||
|
// 将记录转换为数组并根据 save_time 由近到远排序
|
||||||
|
const recordsArray = Object.entries(allRecords).map(
|
||||||
|
([key, record]) => ({
|
||||||
|
...record,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 按 save_time 降序排序(最新的在前面)
|
||||||
|
const sortedRecords = recordsArray.sort(
|
||||||
|
(a, b) => b.save_time - a.save_time
|
||||||
|
);
|
||||||
|
|
||||||
|
setPlayRecords(sortedRecords);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取播放记录失败:', error);
|
||||||
|
setPlayRecords([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPlayRecords();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 如果没有播放记录,则不渲染组件
|
||||||
|
if (!loading && playRecords.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算播放进度百分比
|
||||||
|
const getProgress = (record: PlayRecord) => {
|
||||||
|
if (record.total_time === 0) return 0;
|
||||||
|
return (record.play_time / record.total_time) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从 key 中解析 source 和 id
|
||||||
|
const parseKey = (key: string) => {
|
||||||
|
const [source, id] = key.split('+');
|
||||||
|
return { source, id };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`mb-8 ${className || ''}`}>
|
||||||
|
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
|
||||||
|
继续观看
|
||||||
|
</h2>
|
||||||
|
<ScrollableRow>
|
||||||
|
{loading
|
||||||
|
? // 加载状态显示灰色占位数据
|
||||||
|
Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div key={index} className='min-w-[180px] w-44'>
|
||||||
|
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse'>
|
||||||
|
<div className='absolute inset-0 bg-gray-300'></div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse'></div>
|
||||||
|
<div className='mt-1 h-3 bg-gray-200 rounded animate-pulse'></div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: // 显示真实数据
|
||||||
|
playRecords.map((record) => {
|
||||||
|
const { source, id } = parseKey(record.key);
|
||||||
|
return (
|
||||||
|
<div key={record.key} className='min-w-[180px] w-44'>
|
||||||
|
<VideoCard
|
||||||
|
id={id}
|
||||||
|
title={record.title}
|
||||||
|
poster={record.cover}
|
||||||
|
source={source}
|
||||||
|
source_name={record.source_name}
|
||||||
|
progress={getProgress(record)}
|
||||||
|
episodes={record.total_episodes}
|
||||||
|
currentEpisode={record.index}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollableRow>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ interface VideoCardProps {
|
|||||||
source_name: string;
|
source_name: string;
|
||||||
progress?: number;
|
progress?: number;
|
||||||
from?: string;
|
from?: string;
|
||||||
|
currentEpisode?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckCircleCustom() {
|
function CheckCircleCustom() {
|
||||||
@@ -76,6 +77,7 @@ export default function VideoCard({
|
|||||||
source_name,
|
source_name,
|
||||||
progress,
|
progress,
|
||||||
from,
|
from,
|
||||||
|
currentEpisode,
|
||||||
}: VideoCardProps) {
|
}: VideoCardProps) {
|
||||||
const [playHover, setPlayHover] = useState(false);
|
const [playHover, setPlayHover] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -130,6 +132,15 @@ export default function VideoCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 当前播放集数 */}
|
||||||
|
{currentEpisode && (
|
||||||
|
<div className='absolute top-2 left-2 w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center'>
|
||||||
|
<span className='text-white text-xs font-bold'>
|
||||||
|
{currentEpisode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 信息层 */}
|
{/* 信息层 */}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { openGraph } from '@/lib/og';
|
|
||||||
|
|
||||||
describe('Open Graph function should work correctly', () => {
|
|
||||||
it('should not return templateTitle when not specified', () => {
|
|
||||||
const result = openGraph({
|
|
||||||
description: 'Test description',
|
|
||||||
siteName: 'Test site name',
|
|
||||||
});
|
|
||||||
expect(result).not.toContain('&templateTitle=');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return templateTitle when specified', () => {
|
|
||||||
const result = openGraph({
|
|
||||||
templateTitle: 'Test Template Title',
|
|
||||||
description: 'Test description',
|
|
||||||
siteName: 'Test site name',
|
|
||||||
});
|
|
||||||
expect(result).toContain('&templateTitle=Test%20Template%20Title');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -8,11 +8,23 @@ export interface ApiSite {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorageConfig {
|
||||||
|
type: 'localstorage' | 'database';
|
||||||
|
database?: {
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
database?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
cache_time?: number;
|
cache_time?: number;
|
||||||
api_site: {
|
api_site: {
|
||||||
[key: string]: ApiSite;
|
[key: string]: ApiSite;
|
||||||
};
|
};
|
||||||
|
storage?: StorageConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const API_CONFIG = {
|
export const API_CONFIG = {
|
||||||
@@ -62,3 +74,8 @@ export function getApiSites(): ApiSite[] {
|
|||||||
key,
|
key,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getStorageConfig(): StorageConfig {
|
||||||
|
const config = getConfig();
|
||||||
|
return config.storage || { type: 'localstorage' };
|
||||||
|
}
|
||||||
|
|||||||
166
src/lib/db.client.ts
Normal file
166
src/lib/db.client.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅在浏览器端使用的数据库工具,目前基于 localStorage 实现。
|
||||||
|
* 之所以单独拆分文件,是为了避免在客户端 bundle 中引入 `fs`, `path` 等 Node.js 内置模块,
|
||||||
|
* 从而解决诸如 "Module not found: Can't resolve 'fs'" 的问题。
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 获取全部播放记录(getAllPlayRecords)。
|
||||||
|
* 2. 保存播放记录(savePlayRecord)。
|
||||||
|
*
|
||||||
|
* 如后续需要在客户端读取收藏等其它数据,可按同样方式在此文件中补充实现。
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---- 类型 ----
|
||||||
|
export interface PlayRecord {
|
||||||
|
title: string;
|
||||||
|
source_name: string;
|
||||||
|
cover: string;
|
||||||
|
index: number; // 第几集
|
||||||
|
total_episodes: number; // 总集数
|
||||||
|
play_time: number; // 播放进度(秒)
|
||||||
|
total_time: number; // 总进度(秒)
|
||||||
|
save_time: number; // 记录保存时间(时间戳)
|
||||||
|
user_id: number; // 用户 ID,本地存储情况下恒为 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 常量 ----
|
||||||
|
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
||||||
|
|
||||||
|
// ---- 环境变量 ----
|
||||||
|
const STORAGE_TYPE =
|
||||||
|
(process.env.NEXT_PUBLIC_STORAGE_TYPE as
|
||||||
|
| 'localstorage'
|
||||||
|
| 'database'
|
||||||
|
| undefined) || 'localstorage';
|
||||||
|
|
||||||
|
// ---- 工具函数 ----
|
||||||
|
async function fetchFromApi<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(path);
|
||||||
|
if (!res.ok) throw new Error(`请求 ${path} 失败: ${res.status}`);
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成存储key
|
||||||
|
*/
|
||||||
|
export function generateStorageKey(source: string, id: string): string {
|
||||||
|
return `${source}+${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- API ----
|
||||||
|
/**
|
||||||
|
* 读取 localStorage 中的全部播放记录。
|
||||||
|
* 在服务端渲染阶段 (window === undefined) 时返回空对象,避免报错。
|
||||||
|
*/
|
||||||
|
export async function getAllPlayRecords(): Promise<Record<string, PlayRecord>> {
|
||||||
|
// 若配置标明使用数据库,则从后端 API 拉取
|
||||||
|
if (STORAGE_TYPE === 'database') {
|
||||||
|
return fetchFromApi<Record<string, PlayRecord>>('/api/playrecords');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认 / localstorage 流程
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
// 服务器端渲染阶段直接返回空,交由客户端 useEffect 再行请求
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PLAY_RECORDS_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
return JSON.parse(raw) as Record<string, PlayRecord>;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('读取播放记录失败:', err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存播放记录到 localStorage 或通过 API 保存到数据库
|
||||||
|
*/
|
||||||
|
export async function savePlayRecord(
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
record: Omit<PlayRecord, 'user_id'>
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
const fullRecord: PlayRecord = { ...record, user_id: 0 };
|
||||||
|
|
||||||
|
// 若配置标明使用数据库,则通过 API 保存
|
||||||
|
if (STORAGE_TYPE === 'database') {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/playrecords', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, record: fullRecord }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`保存播放记录失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存播放记录到数据库失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认 / localstorage 流程
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
console.warn('无法在服务端保存播放记录到 localStorage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allRecords = await getAllPlayRecords();
|
||||||
|
allRecords[key] = fullRecord;
|
||||||
|
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存播放记录失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除播放记录
|
||||||
|
*/
|
||||||
|
export async function deletePlayRecord(
|
||||||
|
source: string,
|
||||||
|
id: string
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
|
||||||
|
// 若配置标明使用数据库,则通过 API 删除
|
||||||
|
if (STORAGE_TYPE === 'database') {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/playrecords?key=${encodeURIComponent(key)}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`删除播放记录失败: ${res.status}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除播放记录到数据库失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认 / localstorage 流程
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
console.warn('无法在服务端删除播放记录到 localStorage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allRecords = await getAllPlayRecords();
|
||||||
|
delete allRecords[key];
|
||||||
|
localStorage.setItem(PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||||
|
console.log('播放记录已删除:', key);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除播放记录失败:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/lib/db.ts
Normal file
198
src/lib/db.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import { getStorageConfig } from './config';
|
||||||
|
|
||||||
|
// 播放记录数据结构
|
||||||
|
export interface PlayRecord {
|
||||||
|
title: string;
|
||||||
|
source_name: string;
|
||||||
|
cover: string;
|
||||||
|
index: number; // 第几集
|
||||||
|
total_episodes: number; // 总集数
|
||||||
|
play_time: number; // 播放进度(秒)
|
||||||
|
total_time: number; // 总进度(秒)
|
||||||
|
save_time: number; // 记录保存时间(时间戳)
|
||||||
|
user_id: number; // 用户ID,localStorage情况下全部为0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏数据结构
|
||||||
|
export interface Favorite {
|
||||||
|
source_name: string;
|
||||||
|
total_episodes: number; // 总集数
|
||||||
|
title: string;
|
||||||
|
cover: string;
|
||||||
|
user_id: number; // 用户ID,localStorage情况下全部为0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储接口
|
||||||
|
export interface IStorage {
|
||||||
|
// 播放记录相关
|
||||||
|
getPlayRecord(key: string): Promise<PlayRecord | null>;
|
||||||
|
setPlayRecord(key: string, record: PlayRecord): Promise<void>;
|
||||||
|
getAllPlayRecords(): Promise<{ [key: string]: PlayRecord }>;
|
||||||
|
deletePlayRecord(key: string): Promise<void>;
|
||||||
|
|
||||||
|
// 收藏相关
|
||||||
|
getFavorite(key: string): Promise<Favorite | null>;
|
||||||
|
setFavorite(key: string, favorite: Favorite): Promise<void>;
|
||||||
|
getAllFavorites(): Promise<{ [key: string]: Favorite }>;
|
||||||
|
deleteFavorite(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库实现(保留接口,待实现)
|
||||||
|
class DatabaseStorage implements IStorage {
|
||||||
|
async getPlayRecord(_key: string): Promise<PlayRecord | null> {
|
||||||
|
// TODO: 实现数据库查询逻辑
|
||||||
|
throw new Error('Database storage not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPlayRecord(_key: string, _record: PlayRecord): Promise<void> {
|
||||||
|
// TODO: 实现数据库插入/更新逻辑
|
||||||
|
throw new Error('Database storage not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPlayRecords(): Promise<{ [key: string]: PlayRecord }> {
|
||||||
|
// TODO: 实现数据库查询所有记录逻辑
|
||||||
|
throw new Error('Database storage not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlayRecord(_key: string): Promise<void> {
|
||||||
|
// TODO: 实现数据库删除逻辑
|
||||||
|
throw new Error('Database storage not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFavorite(_: string): Promise<Favorite | null> {
|
||||||
|
// TODO: 实现数据库查询逻辑
|
||||||
|
throw new Error('Database storage not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async setFavorite(_key: string, _favorite: Favorite): Promise<void> {
|
||||||
|
// TODO: 实现数据库插入/更新逻辑
|
||||||
|
throw new Error('Database storage not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllFavorites(): Promise<{ [key: string]: Favorite }> {
|
||||||
|
// TODO: 实现数据库查询所有收藏逻辑
|
||||||
|
throw new Error('Database storage not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFavorite(_key: string): Promise<void> {
|
||||||
|
// TODO: 实现数据库删除逻辑
|
||||||
|
throw new Error('Database storage not implemented yet');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建存储实例
|
||||||
|
function createStorage(): IStorage {
|
||||||
|
const config = getStorageConfig();
|
||||||
|
|
||||||
|
switch (config.type) {
|
||||||
|
case 'database':
|
||||||
|
return new DatabaseStorage();
|
||||||
|
case 'localstorage':
|
||||||
|
default:
|
||||||
|
return null as unknown as IStorage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例存储实例
|
||||||
|
let storageInstance: IStorage | null = null;
|
||||||
|
|
||||||
|
export function getStorage(): IStorage {
|
||||||
|
if (!storageInstance) {
|
||||||
|
storageInstance = createStorage();
|
||||||
|
}
|
||||||
|
return storageInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具函数:生成存储key
|
||||||
|
export function generateStorageKey(source: string, id: string): string {
|
||||||
|
return `${source}+${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出便捷方法
|
||||||
|
export class DbManager {
|
||||||
|
private storage: IStorage;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.storage = getStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放记录相关方法
|
||||||
|
async getPlayRecord(source: string, id: string): Promise<PlayRecord | null> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
return this.storage.getPlayRecord(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePlayRecord(
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
record: Omit<PlayRecord, 'user_id'>
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
const fullRecord: PlayRecord = { ...record, user_id: 0 };
|
||||||
|
await this.storage.setPlayRecord(key, fullRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllPlayRecords(): Promise<{ [key: string]: PlayRecord }> {
|
||||||
|
return this.storage.getAllPlayRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePlayRecord(source: string, id: string): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
await this.storage.deletePlayRecord(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏相关方法
|
||||||
|
async getFavorite(source: string, id: string): Promise<Favorite | null> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
return this.storage.getFavorite(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveFavorite(
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
favorite: Omit<Favorite, 'user_id'>
|
||||||
|
): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
const fullFavorite: Favorite = { ...favorite, user_id: 0 };
|
||||||
|
await this.storage.setFavorite(key, fullFavorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllFavorites(): Promise<{ [key: string]: Favorite }> {
|
||||||
|
return this.storage.getAllFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFavorite(source: string, id: string): Promise<void> {
|
||||||
|
const key = generateStorageKey(source, id);
|
||||||
|
await this.storage.deleteFavorite(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isFavorited(source: string, id: string): Promise<boolean> {
|
||||||
|
const favorite = await this.getFavorite(source, id);
|
||||||
|
return favorite !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleFavorite(
|
||||||
|
source: string,
|
||||||
|
id: string,
|
||||||
|
favoriteData?: Omit<Favorite, 'user_id'>
|
||||||
|
): Promise<boolean> {
|
||||||
|
const isFav = await this.isFavorited(source, id);
|
||||||
|
|
||||||
|
if (isFav) {
|
||||||
|
await this.deleteFavorite(source, id);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
if (favoriteData) {
|
||||||
|
await this.saveFavorite(source, id, favoriteData);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error('Favorite data is required when adding to favorites');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出默认实例
|
||||||
|
export const db = new DbManager();
|
||||||
@@ -8,6 +8,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
const envVariables = z.object({
|
const envVariables = z.object({
|
||||||
NEXT_PUBLIC_SHOW_LOGGER: z.enum(['true', 'false']).optional(),
|
NEXT_PUBLIC_SHOW_LOGGER: z.enum(['true', 'false']).optional(),
|
||||||
|
NEXT_PUBLIC_STORAGE_TYPE: z.enum(['localstorage', 'database']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
envVariables.parse(process.env);
|
envVariables.parse(process.env);
|
||||||
|
|||||||
Reference in New Issue
Block a user