feat: implement favorites

This commit is contained in:
shinya
2025-06-20 01:27:31 +08:00
parent 9bf8596ea8
commit cf28d1206d
8 changed files with 511 additions and 113 deletions

View File

@@ -1,13 +1,19 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* 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 } from '@/lib/db.client';
import {
generateStorageKey,
getAllPlayRecords,
isFavorited,
toggleFavorite,
} from '@/lib/db.client';
import { VideoDetail } from '@/lib/video';
import PageLayout from '@/components/PageLayout';
@@ -18,6 +24,7 @@ function DetailPageClient() {
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') || '';
@@ -64,6 +71,14 @@ function DetailPageClient() {
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 {
@@ -74,6 +89,26 @@ function DetailPageClient() {
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-10 py-8 overflow-visible'>
@@ -214,21 +249,20 @@ function DetailPageClient() {
</>
)}
{/* 爱心按钮 */}
<button className='flex items-center justify-center w-10 h-10 bg-pink-400 hover:bg-pink-500 rounded-full transition-colors'>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-5 w-5 text-white'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z'
/>
</svg>
<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>
{/* 播放记录进度条 */}

View File

@@ -11,12 +11,16 @@ import {
} from 'lucide-react';
import { Suspense, useEffect, useState } from 'react';
// 客户端收藏 API
import { getAllFavorites } from '@/lib/db.client';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import CollectionCard from '@/components/CollectionCard';
import ContinueWatching from '@/components/ContinueWatching';
import DemoCard from '@/components/DemoCard';
import PageLayout from '@/components/PageLayout';
import ScrollableRow from '@/components/ScrollableRow';
import VideoCard from '@/components/VideoCard';
interface DoubanItem {
title: string;
@@ -58,6 +62,18 @@ function HomeClient() {
const [hotTvShows, setHotTvShows] = useState<DoubanItem[]>([]);
const [loading, setLoading] = useState(true);
// 收藏夹数据
type FavoriteItem = {
id: string;
source: string;
title: string;
poster: string;
episodes: number;
source_name: string;
};
const [favoriteItems, setFavoriteItems] = useState<FavoriteItem[]>([]);
useEffect(() => {
const fetchDoubanData = async () => {
try {
@@ -86,6 +102,32 @@ function HomeClient() {
fetchDoubanData();
}, []);
// 当切换到收藏夹时加载收藏数据
useEffect(() => {
if (activeTab !== 'favorites') return;
(async () => {
const all = await getAllFavorites();
// 根据保存时间排序(从近到远)
const sorted = Object.entries(all)
.sort(([, a], [, b]) => b.save_time - a.save_time)
.map(([key, fav]) => {
const plusIndex = key.indexOf('+');
const source = key.slice(0, plusIndex);
const id = key.slice(plusIndex + 1);
return {
id,
source,
title: fav.title,
poster: fav.cover,
episodes: fav.total_episodes,
source_name: fav.source_name,
} as FavoriteItem;
});
setFavoriteItems(sorted);
})();
}, [activeTab]);
return (
<PageLayout>
<div className='px-10 py-8'>
@@ -102,76 +144,100 @@ function HomeClient() {
</div>
<div className='max-w-[95%] mx-auto'>
{/* 推荐 */}
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<ScrollableRow scrollDistance={800}>
{collections.map((collection) => (
<div key={collection.title} className='min-w-[280px] w-72'>
<CollectionCard
title={collection.title}
icon={collection.icon}
href={collection.href}
/>
</div>
))}
</ScrollableRow>
</section>
{/* 继续观看 */}
<ContinueWatching />
{/* 热门电影 */}
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).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>
))
: // 显示真实数据
hotMovies.map((movie, index) => (
<div key={index} className='min-w-[180px] w-44'>
<DemoCard title={movie.title} poster={movie.poster} />
{activeTab === 'favorites' ? (
// 收藏夹视图
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<div className='flex flex-wrap gap-x-8 gap-y-20 px-4'>
{favoriteItems.map((item) => (
<div key={item.id + item.source} className='w-44'>
<VideoCard {...item} from='favorites' />
</div>
))}
{favoriteItems.length === 0 && (
<div className='col-span-full text-center text-gray-500 py-8'>
</div>
)}
</div>
</section>
) : (
// 首页视图
<>
{/* 推荐 */}
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<ScrollableRow scrollDistance={800}>
{collections.map((collection) => (
<div key={collection.title} className='min-w-[280px] w-72'>
<CollectionCard
title={collection.title}
icon={collection.icon}
href={collection.href}
/>
</div>
))}
</ScrollableRow>
</section>
</ScrollableRow>
</section>
{/* 热门剧集 */}
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).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>
))
: // 显示真实数据
hotTvShows.map((show, index) => (
<div key={index} className='min-w-[180px] w-44'>
<DemoCard title={show.title} poster={show.poster} />
</div>
))}
</ScrollableRow>
</section>
{/* 继续观看 */}
<ContinueWatching />
{/* 热门电影 */}
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).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>
))
: // 显示真实数据
hotMovies.map((movie, index) => (
<div key={index} className='min-w-[180px] w-44'>
<DemoCard title={movie.title} poster={movie.poster} />
</div>
))}
</ScrollableRow>
</section>
{/* 热门剧集 */}
<section className='mb-8'>
<h2 className='mb-4 text-xl font-bold text-gray-800 text-left'>
</h2>
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).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>
))
: // 显示真实数据
hotTvShows.map((show, index) => (
<div key={index} className='min-w-[180px] w-44'>
<DemoCard title={show.title} poster={show.poster} />
</div>
))}
</ScrollableRow>
</section>
</>
)}
</div>
</div>
</PageLayout>

View File

@@ -2,6 +2,7 @@
'use client';
import { Heart } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { useEffect, useRef, useState } from 'react';
@@ -11,7 +12,9 @@ import {
deletePlayRecord,
generateStorageKey,
getAllPlayRecords,
isFavorited,
savePlayRecord,
toggleFavorite,
} from '@/lib/db.client';
import { VideoDetail } from '@/lib/video';
@@ -83,6 +86,9 @@ function PlayPageClient() {
// 总集数:从 detail 中获取,保证随 detail 更新而变化
const totalEpisodes = detail?.episodes?.length || 0;
// 收藏状态
const [favorited, setFavorited] = useState(false);
// 用于记录是否需要在播放器 ready 后跳转到指定进度
const resumeTimeRef = useRef<number | null>(null);
@@ -973,6 +979,37 @@ function PlayPageClient() {
}
};
// 每当 source 或 id 变化时检查收藏状态
useEffect(() => {
if (!currentSource || !currentId) return;
(async () => {
try {
const fav = await isFavorited(currentSource, currentId);
setFavorited(fav);
} catch (err) {
console.error('检查收藏状态失败:', err);
}
})();
}, [currentSource, currentId]);
// 切换收藏
const handleToggleFavorite = async () => {
if (!currentSource || !currentId) return;
try {
const newState = await toggleFavorite(currentSource, currentId, {
title: videoTitle,
source_name: detail?.videoInfo.source_name || '',
cover: videoCover || '',
total_episodes: totalEpisodes || 1,
save_time: Date.now(),
});
setFavorited(newState);
} catch (err) {
console.error('切换收藏失败:', err);
}
};
if (loading) {
return (
<div className='min-h-screen bg-black flex items-center justify-center'>
@@ -1067,8 +1104,24 @@ function PlayPageClient() {
{/* 中央标题及集数信息 */}
<div className='text-center'>
<div className='text-white font-semibold text-lg truncate max-w-xs mx-auto'>
{videoTitle}
<div className='flex items-center justify-center gap-2 max-w-xs mx-auto'>
<span className='text-white font-semibold text-lg truncate'>
{videoTitle}
</span>
<button
onClick={(e) => {
e.stopPropagation();
handleToggleFavorite();
}}
className='flex-shrink-0'
>
<Heart
className={`h-5 w-5 stroke-[2] ${
favorited ? 'text-red-500' : 'text-gray-300'
}`}
fill={favorited ? 'currentColor' : 'none'}
/>
</button>
</div>
{totalEpisodes > 1 && (

View File

@@ -125,7 +125,7 @@ function SearchPageClient() {
</div>
) : showResults ? (
// 搜索结果
<div className='grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-x-8 gap-y-20 px-4'>
<div className='flex flex-wrap gap-x-8 gap-y-20 px-4'>
{searchResults.map((item) => (
<div key={item.id} className='w-44'>
<VideoCard {...item} from='search' />