/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps */ import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import React, { memo, useCallback, useEffect, useMemo, useState, forwardRef, useImperativeHandle, } from 'react'; import { deleteFavorite, deletePlayRecord, generateStorageKey, isFavorited, saveFavorite, subscribeToDataUpdates, } from '@/lib/db.client'; import { processImageUrl } from '@/lib/utils'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; export interface VideoCardProps { id?: string; source?: string; title?: string; query?: string; poster?: string; episodes?: number; source_name?: string; source_names?: string[]; progress?: number; year?: string; from: 'playrecord' | 'favorite' | 'search' | 'douban'; currentEpisode?: number; douban_id?: number; onDelete?: () => void; rate?: string; type?: string; isBangumi?: boolean; isAggregate?: boolean; } export type VideoCardHandle = { setDoubanId: (id?: number) => void; setEpisodes: (episodes?: number) => void; setSourceNames: (names?: string[]) => void; }; const VideoCard = forwardRef(function VideoCard( { id, title = '', query = '', poster = '', episodes, source, source_name, source_names, progress = 0, year, from, currentEpisode, douban_id, onDelete, rate, type = '', isBangumi = false, isAggregate = false, }: VideoCardProps, ref ) { const router = useRouter(); const [favorited, setFavorited] = useState(false); const [isLoading, setIsLoading] = useState(false); // 可外部修改的可控字段 const [dynamicDoubanId, setDynamicDoubanId] = useState( douban_id ); const [dynamicEpisodes, setDynamicEpisodes] = useState( episodes ); const [dynamicSourceNames, setDynamicSourceNames] = useState( source_names ); useEffect(() => { setDynamicDoubanId(douban_id); }, [douban_id]); useEffect(() => { setDynamicEpisodes(episodes); }, [episodes]); useEffect(() => { setDynamicSourceNames(source_names); }, [source_names]); useImperativeHandle(ref, () => ({ setDoubanId: (id?: number) => setDynamicDoubanId(id), setEpisodes: (eps?: number) => setDynamicEpisodes(eps), setSourceNames: (names?: string[]) => setDynamicSourceNames(names), })); const actualTitle = title; const actualPoster = poster; const actualSource = source; const actualId = id; const actualDoubanId = dynamicDoubanId; const actualEpisodes = dynamicEpisodes; const actualYear = year; const actualQuery = query || ''; const actualSearchType = isAggregate ? (actualEpisodes && actualEpisodes === 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' || (isAggregate && !actualSource && !actualId)) { router.push( `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : '' }${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}` ); } 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 && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => { const uniqueSources = Array.from(new Set(dynamicSourceNames)); 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} )}
); } ); export default memo(VideoCard);