mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-22 18:44:44 +08:00
feat: one VideoCard for all
This commit is contained in:
@@ -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) => (
|
||||
<div key={`${item.title}-${index}`} className='w-full'>
|
||||
<DemoCard
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
source=''
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
source_name=''
|
||||
rate={item.rate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<FavoriteItem[]>([]);
|
||||
@@ -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'
|
||||
>
|
||||
<DemoCard
|
||||
id={movie.id}
|
||||
<VideoCard
|
||||
id=''
|
||||
source=''
|
||||
title={movie.title}
|
||||
poster={movie.poster}
|
||||
source_name=''
|
||||
rate={movie.rate}
|
||||
/>
|
||||
</div>
|
||||
@@ -237,10 +253,12 @@ function HomeClient() {
|
||||
key={index}
|
||||
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
|
||||
>
|
||||
<DemoCard
|
||||
<VideoCard
|
||||
id={show.id}
|
||||
source=''
|
||||
title={show.title}
|
||||
poster={show.poster}
|
||||
source_name=''
|
||||
rate={show.rate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div key={`agg-${mapKey}`} className='w-full'>
|
||||
<AggregateCard items={group} year={group[0].year} />
|
||||
<VideoCard
|
||||
id={group[0].id}
|
||||
source={group[0].source}
|
||||
title={group[0].title}
|
||||
poster={group[0].poster}
|
||||
source_name={group[0].source_name}
|
||||
year={group[0].year}
|
||||
items={group}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<svg
|
||||
width='44'
|
||||
height='44'
|
||||
viewBox='0 0 44 44'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className={className}
|
||||
>
|
||||
<circle
|
||||
cx='22'
|
||||
cy='22'
|
||||
r='20'
|
||||
stroke='white'
|
||||
strokeWidth='1.5'
|
||||
fill={fillColor}
|
||||
/>
|
||||
<polygon points='19,15 19,29 29,22' fill='white' />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 `VideoCard` 基本一致,删除了来源标签、收藏等功能
|
||||
* 点击播放按钮 -> 跳到第一个源播放
|
||||
* 点击卡片其他区域 -> 跳到聚合详情页 (/aggregate)
|
||||
*/
|
||||
const AggregateCard: React.FC<AggregateCardProps> = ({ 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<number, number>();
|
||||
|
||||
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<number, number>();
|
||||
|
||||
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 (
|
||||
<Link
|
||||
href={`/play?source=${first.source}&id=${
|
||||
first.id
|
||||
}&title=${encodeURIComponent(first.title)}${year ? `&year=${year}` : ''}`}
|
||||
>
|
||||
<div className='group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out'>
|
||||
{/* 封面图片 2:3 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md transition-all duration-400 cubic-bezier(0.4,0,0.2,1)'>
|
||||
{/* 图片占位符 - 骨架屏效果 */}
|
||||
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
||||
|
||||
<Image
|
||||
src={first.poster}
|
||||
alt={first.title}
|
||||
fill
|
||||
className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110
|
||||
${
|
||||
isLoaded
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-0 scale-95'
|
||||
}`}
|
||||
onLoadingComplete={() => setIsLoaded(true)}
|
||||
referrerPolicy='no-referrer'
|
||||
priority={false}
|
||||
/>
|
||||
|
||||
{/* Hover 效果层 */}
|
||||
<div className='absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-300 cubic-bezier(0.4,0,0.2,1) flex items-center justify-center overflow-hidden'>
|
||||
{/* 播放按钮 */}
|
||||
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
|
||||
<div
|
||||
className={`transition-all duration-300 cubic-bezier(0.4,0,0.2,1) ${
|
||||
playHover ? 'scale-100 opacity-100' : 'scale-90 opacity-70'
|
||||
}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
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)}
|
||||
>
|
||||
<PlayCircleSolid fillColor={playHover ? '#22c55e' : 'none'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 集数矩形展示框 */}
|
||||
{mostFrequentEpisodes && mostFrequentEpisodes > 1 && (
|
||||
<div className='absolute top-2 right-2 w-7 h-7 sm:w-7 sm:h-7 rounded-full bg-green-500/90 dark:bg-green-600/90 flex items-center justify-center shadow-md text-[0.55rem] sm:text-xs'>
|
||||
<span className='text-white font-bold leading-none'>
|
||||
{mostFrequentEpisodes}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 豆瓣链接按钮 */}
|
||||
{mostFrequentDoubanId && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${mostFrequentDoubanId}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => 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)'
|
||||
>
|
||||
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-[#22c55e] flex items-center justify-center shadow-md opacity-70 hover:opacity-100 transition-all duration-200 ease-in-out hover:scale-110 hover:bg-[#16a34a]'>
|
||||
<LinkIcon className='w-4 h-4 text-white' strokeWidth={2} />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<span className='mt-2 px-1 block text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200 transition-all duration-400 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
||||
{first.title}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default AggregateCard;
|
||||
@@ -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 (
|
||||
<svg
|
||||
width='44'
|
||||
height='44'
|
||||
viewBox='0 0 44 44'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className={`${className} block relative`}
|
||||
>
|
||||
<circle
|
||||
cx='22'
|
||||
cy='22'
|
||||
r='20'
|
||||
stroke='white'
|
||||
strokeWidth='1.5'
|
||||
fill={fillColor}
|
||||
/>
|
||||
<polygon points='19,15 19,29 29,22' fill='white' />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const DemoCard = ({ id, title, poster, rate }: DemoCardProps) => {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const router = useRouter();
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(
|
||||
`/play?title=${encodeURIComponent(title.trim())}&douban_id=${id}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out'
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 海报图片区域 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md group-hover:scale-[1.02] transition-all duration-400 cubic-bezier(0.4, 0, 0.2, 1) safari-fix'>
|
||||
{/* 图片占位符 - 骨架屏效果 */}
|
||||
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
||||
|
||||
{/* 图片组件 */}
|
||||
<Image
|
||||
src={poster}
|
||||
alt={title}
|
||||
fill
|
||||
ref={imgRef}
|
||||
className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110
|
||||
${
|
||||
isLoaded
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-0 scale-95'
|
||||
}`}
|
||||
onLoadingComplete={() => setIsLoaded(true)}
|
||||
referrerPolicy='no-referrer'
|
||||
priority={false}
|
||||
/>
|
||||
|
||||
{/* 评分徽章 - 暗色模式优化 */}
|
||||
{rate && (
|
||||
<div className='absolute top-2 right-2 min-w-[1.25rem] h-4 w-4 sm:h-7 sm:w-7 sm:min-w-[1.5rem] bg-pink-500 dark:bg-pink-400 rounded-full flex items-center justify-center px-1 shadow-md transform transition-all duration-300 cubic-bezier(0.4, 0, 0.2, 1) group-hover:scale-110 group-hover:rotate-3'>
|
||||
<span className='text-white text-[0.5rem] sm:text-xs font-bold leading-none'>
|
||||
{rate}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬浮层 - 搜索按钮 */}
|
||||
<div className='absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500 cubic-bezier(0.4, 0, 0.2, 1) flex items-center justify-center'>
|
||||
<div
|
||||
onMouseEnter={() => 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'
|
||||
}`}
|
||||
>
|
||||
<PlayCircleSolid fillColor={hover ? '#22c55e' : 'none'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 外部链接按钮 - 暗色模式优化 */}
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${id}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => 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)'
|
||||
>
|
||||
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-[#22c55e] dark:bg-[#16a34a] flex items-center justify-center shadow-md opacity-70 hover:opacity-100 transition-all duration-200 ease-in-out hover:scale-110 hover:bg-[#16a34a] dark:hover:bg-[#15803d]'>
|
||||
<LinkIcon className='w-4 h-4 text-white' strokeWidth={2} />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 信息层 - 暗色模式优化 */}
|
||||
<span className='mt-2 px-1 block font-semibold truncate w-full text-center text-xs sm:text-sm transition-all duration-400 cubic-bezier(0.4, 0, 0.2, 1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100'>
|
||||
<span className='text-gray-900 dark:text-gray-200 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemoCard;
|
||||
@@ -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<number, number>();
|
||||
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<number, number>();
|
||||
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<HTMLSpanElement | SVGElement, 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<HTMLSpanElement | SVGElement, 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 (
|
||||
<Link
|
||||
href={`/play?source=${source}&id=${id}&title=${encodeURIComponent(
|
||||
title.trim()
|
||||
)}${year ? `&year=${year}` : ''}${from ? `&from=${from}` : ''}`}
|
||||
<div
|
||||
className={`group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out ${
|
||||
isDeleting ? 'opacity-0 scale-90' : ''
|
||||
} ${isDemo ? 'group-hover:scale-[1.02]' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div
|
||||
className={`group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out ${
|
||||
isDeleting ? 'opacity-0 scale-90' : ''
|
||||
}`}
|
||||
>
|
||||
{/* 海报图片容器 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md transition-all duration-400 cubic-bezier(0.4,0,0.2,1)'>
|
||||
{/* 图片占位符 - 骨架屏效果 */}
|
||||
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
||||
{/* 海报图片容器 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg transition-all duration-400 cubic-bezier(0.4,0,0.2,1)'>
|
||||
{/* 图片占位符 - 骨架屏效果 */}
|
||||
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
||||
|
||||
<Image
|
||||
src={poster}
|
||||
alt={title}
|
||||
fill
|
||||
className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110
|
||||
<Image
|
||||
src={actualPoster}
|
||||
alt={actualTitle}
|
||||
fill
|
||||
className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110
|
||||
${
|
||||
isLoaded
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-0 scale-95'
|
||||
}`}
|
||||
onLoadingComplete={() => setIsLoaded(true)}
|
||||
referrerPolicy='no-referrer'
|
||||
priority={false}
|
||||
/>
|
||||
{/* Hover 效果层 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent ${
|
||||
alwaysShowHeart
|
||||
? 'opacity-50 group-hover:opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100'
|
||||
} transition-all duration-300 cubic-bezier(0.4,0,0.2,1) flex items-center justify-center overflow-hidden`}
|
||||
>
|
||||
{/* 播放按钮 */}
|
||||
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
|
||||
<div
|
||||
className={`transition-all duration-300 cubic-bezier(0.4,0,0.2,1) ${
|
||||
playHover ? 'scale-100 opacity-100' : 'scale-90 opacity-70'
|
||||
}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(
|
||||
`/play?source=${source}&id=${id}&title=${encodeURIComponent(
|
||||
title
|
||||
)}${year ? `&year=${year}` : ''}`
|
||||
);
|
||||
}}
|
||||
onMouseEnter={() => setPlayHover(true)}
|
||||
onMouseLeave={() => setPlayHover(false)}
|
||||
>
|
||||
<PlayCircleSolid fillColor={playHover ? '#22c55e' : 'none'} />
|
||||
</div>
|
||||
onLoadingComplete={() => setIsLoaded(true)}
|
||||
referrerPolicy='no-referrer'
|
||||
priority={false}
|
||||
/>
|
||||
{/* Hover 效果层 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent ${showHoverLayer} transition-all duration-300 cubic-bezier(0.4,0,0.2,1) flex items-center justify-center overflow-hidden`}
|
||||
>
|
||||
{/* 播放按钮 */}
|
||||
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
|
||||
<div
|
||||
className={`transition-all duration-300 cubic-bezier(0.4,0,0.2,1) ${
|
||||
playHover ? 'scale-100 opacity-100' : 'scale-90 opacity-70'
|
||||
} ${isDemo && playHover ? 'scale-110 rotate-12' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={handlePlayClick}
|
||||
onMouseEnter={() => setPlayHover(true)}
|
||||
onMouseLeave={() => setPlayHover(false)}
|
||||
>
|
||||
<PlayCircleSolid fillColor={playHover ? '#22c55e' : 'none'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧操作按钮组 */}
|
||||
{/* 右侧操作按钮组(仅标准卡片) */}
|
||||
{isStandard && (
|
||||
<div className='absolute bottom-2 right-2 sm:bottom-4 sm:right-4 flex items-center gap-3 transform transition-all duration-300 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110'>
|
||||
{!hideCheckCircle && (
|
||||
<span
|
||||
@@ -240,67 +345,91 @@ export default function VideoCard({
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 评分徽章(如果有rate字段) */}
|
||||
{rate && (
|
||||
<div className='absolute top-2 right-2 min-w-[1.25rem] h-4 w-4 sm:h-7 sm:w-7 sm:min-w-[1.5rem] bg-pink-500 dark:bg-pink-400 rounded-full flex items-center justify-center px-1 shadow-md transform transition-all duration-300 cubic-bezier(0.4, 0, 0.2, 1) group-hover:scale-110 group-hover:rotate-3'>
|
||||
<span className='text-white text-[0.5rem] sm:text-xs font-bold leading-none'>
|
||||
{rate}
|
||||
</span>
|
||||
</div>
|
||||
{/* 继续观看 - 集数矩形展示框 */}
|
||||
{episodes && episodes > 1 && currentEpisode && (
|
||||
)}
|
||||
|
||||
{/* 继续观看 - 集数矩形展示框(标准卡片) */}
|
||||
{isStandard &&
|
||||
actualEpisodes &&
|
||||
actualEpisodes > 1 &&
|
||||
currentEpisode && (
|
||||
<div className='absolute top-2 right-2 min-w-[1.875rem] h-5 sm:h-7 sm:min-w-[2.5rem] bg-green-500/90 dark:bg-green-600/90 rounded-md flex items-center justify-center px-2 shadow-md text-[0.55rem] sm:text-xs'>
|
||||
<span className='text-white font-bold leading-none'>
|
||||
{currentEpisode}
|
||||
<span className='mx-1 text-white/80'>/</span>
|
||||
</span>
|
||||
<span className='text-white font-bold leading-none'>
|
||||
{episodes}
|
||||
{actualEpisodes}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 搜索非聚合 - 集数圆形展示框 */}
|
||||
{episodes && episodes > 1 && !currentEpisode && (
|
||||
|
||||
{/* 搜索非聚合/聚合 - 集数圆形展示框 */}
|
||||
{(isStandard || isAggregate) &&
|
||||
actualEpisodes &&
|
||||
actualEpisodes > 1 &&
|
||||
!currentEpisode && (
|
||||
<div className='absolute top-2 right-2 w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-green-500/90 dark:bg-green-600/90 flex items-center justify-center shadow-md text-[0.55rem] sm:text-xs'>
|
||||
<span className='text-white font-bold leading-none'>
|
||||
{episodes}
|
||||
{actualEpisodes}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 豆瓣链接按钮 */}
|
||||
{douban_id && from === 'search' && (
|
||||
|
||||
{/* 豆瓣链接按钮 */}
|
||||
{actualDoubanId &&
|
||||
(isDemo || (isStandard && from === 'search') || isAggregate) && (
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${douban_id}`}
|
||||
href={`https://movie.douban.com/subject/${actualDoubanId}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
onClick={(e) => 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)'
|
||||
>
|
||||
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-[#22c55e] flex items-center justify-center shadow-md opacity-70 hover:opacity-100 transition-all duration-200 ease-in-out hover:scale-110 hover:bg-[#16a34a]'>
|
||||
<div
|
||||
className={`w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-[#22c55e] ${
|
||||
isDemo ? 'dark:bg-[#16a34a]' : ''
|
||||
} flex items-center justify-center shadow-md opacity-70 hover:opacity-100 transition-all duration-200 ease-in-out hover:scale-110 hover:bg-[#16a34a] ${
|
||||
isDemo ? 'dark:hover:bg-[#15803d]' : ''
|
||||
}`}
|
||||
>
|
||||
<LinkIcon className='w-4 h-4 text-white' strokeWidth={2} />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 播放进度条 - 移至图片容器外部,标题上方 */}
|
||||
{progress !== undefined && (
|
||||
<div className='mt-1 h-1 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden'>
|
||||
<div
|
||||
className='h-full bg-[#22c55e] rounded-full transition-all duration-200'
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 信息层 */}
|
||||
<span className='mt-2 px-1 block text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200 transition-all duration-400 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{/* 来源信息 */}
|
||||
{source && (
|
||||
<span className='mt-1 px-1 block text-gray-500 text-[0.5rem] sm:text-xs w-full text-center dark:text-gray-400 transition-all duration-400 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100'>
|
||||
<span className='inline-block border border-gray-500/60 rounded px-2 py-[1px] dark:border-gray-400/60'>
|
||||
{source_name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* 播放进度条(仅标准卡片) */}
|
||||
{isStandard && progress !== undefined && (
|
||||
<div className='mt-1 h-1 w-full bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden'>
|
||||
<div
|
||||
className='h-full bg-[#22c55e] rounded-full transition-all duration-200'
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 信息层 */}
|
||||
<span className='mt-2 px-1 block text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200 transition-all duration-400 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
||||
{actualTitle}
|
||||
</span>
|
||||
|
||||
{/* 来源信息(仅标准卡片) */}
|
||||
{isStandard && actualSource && (
|
||||
<span className='mt-1 px-1 block text-gray-500 text-[0.5rem] sm:text-xs w-full text-center dark:text-gray-400 transition-all duration-400 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100'>
|
||||
<span className='inline-block border border-gray-500/60 rounded px-2 py-[1px] dark:border-gray-400/60'>
|
||||
{source_name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user