/* eslint-disable @typescript-eslint/no-explicit-any */ import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { deleteFavorite, deletePlayRecord, generateStorageKey, isFavorited, saveFavorite, subscribeToDataUpdates, } from '@/lib/db.client'; import { SearchResult } from '@/lib/types'; import { processImageUrl } from '@/lib/utils'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; interface VideoCardProps { id?: string; source?: string; title?: string; query?: string; poster?: string; episodes?: number; source_name?: string; progress?: number; year?: string; from: 'playrecord' | 'favorite' | 'search' | 'douban'; currentEpisode?: number; douban_id?: number; onDelete?: () => void; rate?: string; items?: SearchResult[]; type?: string; isBangumi?: boolean; } export default function VideoCard({ id, title = '', query = '', poster = '', episodes, source, source_name, progress = 0, year, from, currentEpisode, douban_id, onDelete, rate, items, type = '', isBangumi = false, }: VideoCardProps) { const router = useRouter(); const [favorited, setFavorited] = useState(false); const [isLoading, setIsLoading] = useState(false); const isAggregate = from === 'search' && !!items?.length; const aggregateData = useMemo(() => { if (!isAggregate || !items) return null; const countMap = new Map(); const episodeCountMap = 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); } const len = item.episodes?.length || 0; if (len > 0) { episodeCountMap.set(len, (episodeCountMap.get(len) || 0) + 1); } }); const getMostFrequent = (map: Map) => { let maxCount = 0; let result: number | undefined; map.forEach((cnt, key) => { if (cnt > maxCount) { maxCount = cnt; result = key; } }); return result; }; return { first: items[0], mostFrequentDoubanId: getMostFrequent(countMap), mostFrequentEpisodes: getMostFrequent(episodeCountMap) || 0, }; }, [isAggregate, items]); const actualTitle = aggregateData?.first.title ?? title; const actualPoster = aggregateData?.first.poster ?? poster; const actualSource = aggregateData?.first.source ?? source; const actualId = aggregateData?.first.id ?? id; const actualDoubanId = aggregateData?.mostFrequentDoubanId ?? douban_id; const actualEpisodes = aggregateData?.mostFrequentEpisodes ?? episodes; const actualYear = aggregateData?.first.year ?? year; const actualQuery = query || ''; const actualSearchType = isAggregate ? aggregateData?.first.episodes?.length === 1 ? 'movie' : 'tv' : type; // 获取收藏状态(搜索结果页面不检查) useEffect(() => { if (from === 'douban' || from === 'search' || !actualSource || !actualId) return; const fetchFavoriteStatus = async () => { try { const fav = await isFavorited(actualSource, actualId); setFavorited(fav); } catch (err) { throw new Error('检查收藏状态失败'); } }; fetchFavoriteStatus(); // 监听收藏状态更新事件 const storageKey = generateStorageKey(actualSource, actualId); const unsubscribe = subscribeToDataUpdates( 'favoritesUpdated', (newFavorites: Record) => { // 检查当前项目是否在新的收藏列表中 const isNowFavorited = !!newFavorites[storageKey]; setFavorited(isNowFavorited); } ); return unsubscribe; }, [from, actualSource, actualId]); const handleToggleFavorite = useCallback( async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (from === 'douban' || from === 'search' || !actualSource || !actualId) return; try { if (favorited) { // 如果已收藏,删除收藏 await deleteFavorite(actualSource, actualId); setFavorited(false); } else { // 如果未收藏,添加收藏 await saveFavorite(actualSource, actualId, { title: actualTitle, source_name: source_name || '', year: actualYear || '', cover: actualPoster, total_episodes: actualEpisodes ?? 1, save_time: Date.now(), }); setFavorited(true); } } catch (err) { throw new Error('切换收藏状态失败'); } }, [ from, actualSource, actualId, actualTitle, source_name, actualYear, actualPoster, actualEpisodes, favorited, ] ); const handleDeleteRecord = useCallback( async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (from !== 'playrecord' || !actualSource || !actualId) return; try { await deletePlayRecord(actualSource, actualId); onDelete?.(); } catch (err) { throw new Error('删除播放记录失败'); } }, [from, actualSource, actualId, onDelete] ); const handleClick = useCallback(() => { if (from === 'douban') { router.push( `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : '' }${actualSearchType ? `&stype=${actualSearchType}` : ''}` ); } else if (actualSource && actualId) { router.push( `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( actualTitle )}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : '' }${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' }${actualSearchType ? `&stype=${actualSearchType}` : ''}` ); } }, [ from, actualSource, actualId, router, actualTitle, actualYear, isAggregate, actualQuery, actualSearchType, ]); const config = useMemo(() => { const configs = { playrecord: { showSourceName: true, showProgress: true, showPlayButton: true, showHeart: true, showCheckCircle: true, showDoubanLink: false, showRating: false, showYear: false, }, favorite: { showSourceName: true, showProgress: false, showPlayButton: true, showHeart: true, showCheckCircle: false, showDoubanLink: false, showRating: false, showYear: false, }, search: { showSourceName: true, showProgress: false, showPlayButton: true, showHeart: false, showCheckCircle: false, showDoubanLink: false, showRating: false, showYear: true, }, douban: { showSourceName: false, showProgress: false, showPlayButton: true, showHeart: false, showCheckCircle: false, showDoubanLink: true, showRating: !!rate, showYear: false, }, }; return configs[from] || configs.search; }, [from, isAggregate, actualDoubanId, rate]); return (
{/* 海报容器 */}
{/* 骨架屏 */} {!isLoading && } {/* 图片 */} {actualTitle} setIsLoading(true)} onError={(e) => { // 图片加载失败时的重试机制 const img = e.target as HTMLImageElement; if (!img.dataset.retried) { img.dataset.retried = 'true'; setTimeout(() => { img.src = processImageUrl(actualPoster); }, 2000); } }} /> {/* 悬浮遮罩 */}
{/* 播放按钮 */} {config.showPlayButton && (
)} {/* 操作按钮 */} {(config.showHeart || config.showCheckCircle) && (
{config.showCheckCircle && ( )} {config.showHeart && ( )}
)} {/* 年份徽章 */} {config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
{actualYear}
)} {/* 徽章 */} {config.showRating && rate && (
{rate}
)} {actualEpisodes && actualEpisodes > 1 && (
{currentEpisode ? `${currentEpisode}/${actualEpisodes}` : actualEpisodes}
)} {/* 豆瓣链接 */} {config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && ( e.stopPropagation()} className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 group-hover:opacity-100 group-hover:translate-x-0' >
)} {/* 聚合播放源指示器 */} {isAggregate && items && items.length > 0 && (() => { const uniqueSources = Array.from(new Set(items.map(item => item.source_name))); const sourceCount = uniqueSources.length; return (
{sourceCount}
{/* 播放源详情悬浮框 */} {(() => { // 优先显示的播放源(常见的主流平台) const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+']; // 按优先级排序播放源 const sortedSources = uniqueSources.sort((a, b) => { const aIndex = prioritySources.indexOf(a); const bIndex = prioritySources.indexOf(b); if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; if (aIndex !== -1) return -1; if (bIndex !== -1) return 1; return a.localeCompare(b); }); const maxDisplayCount = 6; // 最多显示6个 const displaySources = sortedSources.slice(0, maxDisplayCount); const hasMore = sortedSources.length > maxDisplayCount; const remainingCount = sortedSources.length - maxDisplayCount; return (
{/* 单列布局 */}
{displaySources.map((sourceName, index) => (
{sourceName}
))}
{/* 显示更多提示 */} {hasMore && (
+{remainingCount} 播放源
)} {/* 小箭头 */}
); })()}
); })()}
{/* 进度条 */} {config.showProgress && progress !== undefined && (
)} {/* 标题与来源 */}
{actualTitle} {/* 自定义 tooltip */}
{actualTitle}
{config.showSourceName && source_name && ( {source_name} )}
); }