diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx index 362befa..e4a9c86 100644 --- a/src/app/douban/page.tsx +++ b/src/app/douban/page.tsx @@ -6,9 +6,9 @@ import { useEffect, useRef, useState } from 'react'; import { DoubanItem, DoubanResult } from '@/lib/types'; -import DemoCard from '@/components/DemoCard'; import DoubanCardSkeleton from '@/components/DoubanCardSkeleton'; import PageLayout from '@/components/PageLayout'; +import VideoCard from '@/components/VideoCard'; function DoubanPageClient() { const searchParams = useSearchParams(); @@ -198,10 +198,12 @@ function DoubanPageClient() { : // 显示实际数据 doubanData.map((item, index) => (
-
diff --git a/src/app/page.tsx b/src/app/page.tsx index 25c1c17..390d16d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,12 +5,15 @@ import Link from 'next/link'; import { Suspense, useEffect, useState } from 'react'; // 客户端收藏 API -import { clearAllFavorites, getAllFavorites } from '@/lib/db.client'; +import { + clearAllFavorites, + getAllFavorites, + getAllPlayRecords, +} from '@/lib/db.client'; import { DoubanItem, DoubanResult } from '@/lib/types'; import CapsuleSwitch from '@/components/CapsuleSwitch'; import ContinueWatching from '@/components/ContinueWatching'; -import DemoCard from '@/components/DemoCard'; import PageLayout from '@/components/PageLayout'; import ScrollableRow from '@/components/ScrollableRow'; import { useSite } from '@/components/SiteProvider'; @@ -40,6 +43,7 @@ function HomeClient() { poster: string; episodes: number; source_name: string; + currentEpisode?: number; }; const [favoriteItems, setFavoriteItems] = useState([]); @@ -77,14 +81,23 @@ function HomeClient() { if (activeTab !== 'favorites') return; (async () => { - const all = await getAllFavorites(); + const [allFavorites, allPlayRecords] = await Promise.all([ + getAllFavorites(), + getAllPlayRecords(), + ]); + // 根据保存时间排序(从近到远) - const sorted = Object.entries(all) + const sorted = Object.entries(allFavorites) .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); + + // 查找对应的播放记录,获取当前集数 + const playRecord = allPlayRecords[key]; + const currentEpisode = playRecord?.index; + return { id, source, @@ -93,6 +106,7 @@ function HomeClient() { poster: fav.cover, episodes: fav.total_episodes, source_name: fav.source_name, + currentEpisode, } as FavoriteItem; }); setFavoriteItems(sorted); @@ -192,10 +206,12 @@ function HomeClient() { key={index} className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' > - @@ -237,10 +253,12 @@ function HomeClient() { key={index} className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44' > - diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 958f3c8..6a0dc32 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -13,7 +13,6 @@ import { } from '@/lib/db.client'; import { SearchResult } from '@/lib/types'; -import AggregateCard from '@/components/AggregateCard'; import PageLayout from '@/components/PageLayout'; import VideoCard from '@/components/VideoCard'; @@ -186,7 +185,15 @@ function SearchPageClient() { ? aggregatedResults.map(([mapKey, group]) => { return (
- +
); }) diff --git a/src/components/AggregateCard.tsx b/src/components/AggregateCard.tsx deleted file mode 100644 index fa79569..0000000 --- a/src/components/AggregateCard.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { LinkIcon } from 'lucide-react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import React, { useMemo, useState } from 'react'; - -import { ImagePlaceholder } from '@/components/ImagePlaceholder'; - -// 聚合卡需要的基本字段,与搜索接口保持一致 -interface SearchResult { - id: string; - title: string; - poster: string; - source: string; - source_name: string; - douban_id?: number; - episodes: string[]; -} - -interface AggregateCardProps { - /** 同一标题下的多个搜索结果 */ - year?: string; - items: SearchResult[]; -} - -function PlayCircleSolid({ - className = '', - fillColor = 'none', -}: { - className?: string; - fillColor?: string; -}) { - return ( - - - - - ); -} - -/** - * 与 `VideoCard` 基本一致,删除了来源标签、收藏等功能 - * 点击播放按钮 -> 跳到第一个源播放 - * 点击卡片其他区域 -> 跳到聚合详情页 (/aggregate) - */ -const AggregateCard: React.FC = ({ year = 0, items }) => { - // 使用列表中的第一个结果做展示 & 播放 - const first = items[0]; - const [playHover, setPlayHover] = useState(false); - const [isLoaded, setIsLoaded] = useState(false); - const router = useRouter(); - - // 统计 items 中出现次数最多的(非 0) douban_id,用于跳转豆瓣页面 - const mostFrequentDoubanId = useMemo(() => { - const countMap = new Map(); - - items.forEach((item) => { - if (item.douban_id && item.douban_id !== 0) { - countMap.set(item.douban_id, (countMap.get(item.douban_id) || 0) + 1); - } - }); - - let selectedId: number | undefined; - let maxCount = 0; - - countMap.forEach((cnt, id) => { - if (cnt > maxCount) { - maxCount = cnt; - selectedId = id; - } - }); - - return selectedId; - }, [items]); - - // 统计出现次数最多的集数(episodes.length),主要用于显示剧集数徽标 - const mostFrequentEpisodes = useMemo(() => { - const countMap = new Map(); - - items.forEach((item) => { - const len = item.episodes?.length || 0; - if (len > 0) { - countMap.set(len, (countMap.get(len) || 0) + 1); - } - }); - - let selectedLen = 0; - let maxCount = 0; - - countMap.forEach((cnt, len) => { - if (cnt > maxCount) { - maxCount = cnt; - selectedLen = len; - } - }); - - return selectedLen; - }, [items]); - - return ( - -
- {/* 封面图片 2:3 */} -
- {/* 图片占位符 - 骨架屏效果 */} - - - {first.title} setIsLoaded(true)} - referrerPolicy='no-referrer' - priority={false} - /> - - {/* Hover 效果层 */} -
- {/* 播放按钮 */} -
-
{ - e.preventDefault(); - e.stopPropagation(); - router.push( - `/play?source=${first.source}&id=${ - first.id - }&title=${encodeURIComponent(first.title)}${ - year ? `&year=${year}` : '' - }` - ); - }} - onMouseEnter={() => setPlayHover(true)} - onMouseLeave={() => setPlayHover(false)} - > - -
-
-
- - {/* 集数矩形展示框 */} - {mostFrequentEpisodes && mostFrequentEpisodes > 1 && ( -
- - {mostFrequentEpisodes} - -
- )} - - {/* 豆瓣链接按钮 */} - {mostFrequentDoubanId && ( - e.stopPropagation()} - className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-300 cubic-bezier(0.4,0,0.2,1)' - > -
- -
-
- )} -
- - {/* 标题 */} - - {first.title} - -
- - ); -}; - -export default AggregateCard; diff --git a/src/components/DemoCard.tsx b/src/components/DemoCard.tsx deleted file mode 100644 index 237b901..0000000 --- a/src/components/DemoCard.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { Link as LinkIcon } from 'lucide-react'; -import Image from 'next/image'; -import { useRouter } from 'next/navigation'; -import React, { useRef, useState } from 'react'; - -import { ImagePlaceholder } from '@/components/ImagePlaceholder'; - -interface DemoCardProps { - id: string; - title: string; - poster: string; - rate?: string; -} - -function PlayCircleSolid({ - className = '', - fillColor = 'none', -}: { - className?: string; - fillColor?: string; -}) { - return ( - - - - - ); -} - -const DemoCard = ({ id, title, poster, rate }: DemoCardProps) => { - const [hover, setHover] = useState(false); - const [isLoaded, setIsLoaded] = useState(false); - const router = useRouter(); - const imgRef = useRef(null); - - const handleClick = () => { - router.push( - `/play?title=${encodeURIComponent(title.trim())}&douban_id=${id}` - ); - }; - - return ( -
- {/* 海报图片区域 */} -
- {/* 图片占位符 - 骨架屏效果 */} - - - {/* 图片组件 */} - {title} setIsLoaded(true)} - referrerPolicy='no-referrer' - priority={false} - /> - - {/* 评分徽章 - 暗色模式优化 */} - {rate && ( -
- - {rate} - -
- )} - - {/* 悬浮层 - 搜索按钮 */} -
-
setHover(true)} - onMouseLeave={() => setHover(false)} - className={`transition-all duration-300 cubic-bezier(0.4, 0, 0.2, 1) ${ - hover ? 'scale-110 rotate-12' : 'scale-90' - }`} - > - -
-
- - {/* 外部链接按钮 - 暗色模式优化 */} - e.stopPropagation()} - className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-300 cubic-bezier(0.4, 0, 0.2, 1)' - > -
- -
-
-
- - {/* 信息层 - 暗色模式优化 */} - - - {title} - - -
- ); -}; - -export default DemoCard; diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 3b114f0..99b5096 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1,13 +1,23 @@ import { Heart, Link as LinkIcon } from 'lucide-react'; import Image from 'next/image'; -import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { deletePlayRecord, isFavorited, toggleFavorite } from '@/lib/db.client'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; +// 聚合卡需要的基本字段,与搜索接口保持一致 +interface SearchResult { + id: string; + title: string; + poster: string; + source: string; + source_name: string; + douban_id?: number; + episodes: string[]; +} + interface VideoCardProps { id: string; source: string; @@ -21,6 +31,10 @@ interface VideoCardProps { currentEpisode?: number; douban_id?: number; onDelete?: () => void; + + // 可选属性,根据存在与否决定卡片行为 + rate?: string; // 如果存在,按demo卡片处理 + items?: SearchResult[]; // 如果存在,按aggregate卡片处理 } function CheckCircleCustom() { @@ -88,6 +102,8 @@ export default function VideoCard({ currentEpisode, douban_id, onDelete, + rate, + items, }: VideoCardProps) { const [playHover, setPlayHover] = useState(false); const [favorited, setFavorited] = useState(false); @@ -95,32 +111,108 @@ export default function VideoCard({ const [isDeleting, setIsDeleting] = useState(false); const router = useRouter(); - // 检查初始收藏状态 + // 判断卡片类型 + const isDemo = !!rate; + const isAggregate = !!items && items.length > 0; + const isStandard = !isDemo && !isAggregate; + + // 处理聚合卡片的逻辑 + const aggregateData = useMemo(() => { + if (!isAggregate) { + return null; + } + + const first = items[0]; + + // 统计出现次数最多的(非 0) douban_id + const countMap = new Map(); + items.forEach((item) => { + if (item.douban_id && item.douban_id !== 0) { + countMap.set(item.douban_id, (countMap.get(item.douban_id) || 0) + 1); + } + }); + + let mostFrequentDoubanId: number | undefined; + let maxCount = 0; + countMap.forEach((cnt, id) => { + if (cnt > maxCount) { + maxCount = cnt; + mostFrequentDoubanId = id; + } + }); + + // 统计最频繁的集数 + const episodeCountMap = new Map(); + items.forEach((item) => { + const len = item.episodes?.length || 0; + if (len > 0) { + episodeCountMap.set(len, (episodeCountMap.get(len) || 0) + 1); + } + }); + + let mostFrequentEpisodes = 0; + let maxEpisodeCount = 0; + episodeCountMap.forEach((cnt, len) => { + if (cnt > maxEpisodeCount) { + maxEpisodeCount = cnt; + mostFrequentEpisodes = len; + } + }); + + return { + first, + mostFrequentDoubanId, + mostFrequentEpisodes, + }; + }, [isAggregate, items]); + + // 根据卡片类型决定实际使用的数据 + const actualTitle = + isAggregate && aggregateData ? aggregateData.first.title : title; + const actualPoster = + isAggregate && aggregateData ? aggregateData.first.poster : poster; + const actualSource = + isAggregate && aggregateData ? aggregateData.first.source : source; + const actualId = isAggregate && aggregateData ? aggregateData.first.id : id; + const actualDoubanId = + isAggregate && aggregateData + ? aggregateData.mostFrequentDoubanId + : douban_id; + const actualEpisodes = + isAggregate && aggregateData + ? aggregateData.mostFrequentEpisodes + : episodes; + + // 检查初始收藏状态(仅标准卡片) useEffect(() => { + if (!isStandard) return; + (async () => { try { - const fav = await isFavorited(source, id); + const fav = await isFavorited(actualSource, actualId); setFavorited(fav); } catch (err) { throw new Error('检查收藏状态失败'); } })(); - }, [source, id]); + }, [isStandard, actualSource, actualId]); - // 切换收藏状态 + // 切换收藏状态(仅标准卡片) const handleToggleFavorite = async ( e: React.MouseEvent ) => { e.preventDefault(); e.stopPropagation(); + if (!isStandard) return; + try { - const newState = await toggleFavorite(source, id, { - title, + const newState = await toggleFavorite(actualSource, actualId, { + title: actualTitle, source_name, year: year || '', - cover: poster, - total_episodes: episodes ?? 1, + cover: actualPoster, + total_episodes: actualEpisodes ?? 1, save_time: Date.now(), }); setFavorited(newState); @@ -133,86 +225,99 @@ export default function VideoCard({ } }; - // 删除对应播放记录 + // 删除对应播放记录(仅标准卡片) const handleDeleteRecord = async ( e: React.MouseEvent ) => { e.preventDefault(); e.stopPropagation(); + if (!isStandard) return; + try { - await deletePlayRecord(source, id); + await deletePlayRecord(actualSource, actualId); onDelete?.(); } catch (err) { throw new Error('删除播放记录失败'); } }; - const hideCheckCircle = from === 'favorites' || from === 'search'; - const alwaysShowHeart = from !== 'favorites'; + // 点击处理逻辑 + const handleClick = () => { + if (isDemo) { + router.push(`/play?title=${encodeURIComponent(actualTitle.trim())}`); + } else { + router.push( + `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( + actualTitle.trim() + )}${year ? `&year=${year}` : ''}` + ); + } + }; + + // 播放按钮点击处理 + const handlePlayClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + handleClick(); + }; + + const hideCheckCircle = + from === 'favorites' || from === 'search' || !isStandard; + const alwaysShowHeart = from !== 'favorites' && isStandard; + const showHoverLayer = isStandard + ? alwaysShowHeart + ? 'opacity-50 group-hover:opacity-100' + : 'opacity-0 group-hover:opacity-100' + : 'opacity-0 group-hover:opacity-100'; return ( - -
- {/* 海报图片容器 */} -
- {/* 图片占位符 - 骨架屏效果 */} - + {/* 海报图片容器 */} +
+ {/* 图片占位符 - 骨架屏效果 */} + - {title} setIsLoaded(true)} - referrerPolicy='no-referrer' - priority={false} - /> - {/* Hover 效果层 */} -
- {/* 播放按钮 */} -
-
{ - e.preventDefault(); - e.stopPropagation(); - router.push( - `/play?source=${source}&id=${id}&title=${encodeURIComponent( - title - )}${year ? `&year=${year}` : ''}` - ); - }} - onMouseEnter={() => setPlayHover(true)} - onMouseLeave={() => setPlayHover(false)} - > - -
+ onLoadingComplete={() => setIsLoaded(true)} + referrerPolicy='no-referrer' + priority={false} + /> + {/* Hover 效果层 */} +
+ {/* 播放按钮 */} +
+
setPlayHover(true)} + onMouseLeave={() => setPlayHover(false)} + > +
+
- {/* 右侧操作按钮组 */} + {/* 右侧操作按钮组(仅标准卡片) */} + {isStandard && (
{!hideCheckCircle && (
+ )} +
+ {/* 评分徽章(如果有rate字段) */} + {rate && ( +
+ + {rate} +
- {/* 继续观看 - 集数矩形展示框 */} - {episodes && episodes > 1 && currentEpisode && ( + )} + + {/* 继续观看 - 集数矩形展示框(标准卡片) */} + {isStandard && + actualEpisodes && + actualEpisodes > 1 && + currentEpisode && (
{currentEpisode} / - {episodes} + {actualEpisodes}
)} - {/* 搜索非聚合 - 集数圆形展示框 */} - {episodes && episodes > 1 && !currentEpisode && ( + + {/* 搜索非聚合/聚合 - 集数圆形展示框 */} + {(isStandard || isAggregate) && + actualEpisodes && + actualEpisodes > 1 && + !currentEpisode && (
- {episodes} + {actualEpisodes}
)} - {/* 豆瓣链接按钮 */} - {douban_id && from === 'search' && ( + + {/* 豆瓣链接按钮 */} + {actualDoubanId && + (isDemo || (isStandard && from === 'search') || isAggregate) && ( e.stopPropagation()} className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-300 cubic-bezier(0.4,0,0.2,1)' > -
+
)} -
- - {/* 播放进度条 - 移至图片容器外部,标题上方 */} - {progress !== undefined && ( -
-
-
- )} - - {/* 信息层 */} - - {title} - - - {/* 来源信息 */} - {source && ( - - - {source_name} - - - )}
- + + {/* 播放进度条(仅标准卡片) */} + {isStandard && progress !== undefined && ( +
+
+
+ )} + + {/* 信息层 */} + + {actualTitle} + + + {/* 来源信息(仅标准卡片) */} + {isStandard && actualSource && ( + + + {source_name} + + + )} +
); }