/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */ import { ExternalLink, Heart, Link, PlayCircleIcon, Radio, Trash2 } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useState, } from 'react'; import { deleteFavorite, deletePlayRecord, generateStorageKey, isFavorited, saveFavorite, subscribeToDataUpdates, } from '@/lib/db.client'; import { processImageUrl } from '@/lib/utils'; import { useLongPress } from '@/hooks/useLongPress'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; import MobileActionSheet from '@/components/MobileActionSheet'; 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' | 'shortdrama'; currentEpisode?: number; douban_id?: number; onDelete?: () => void; rate?: string; type?: string; isBangumi?: boolean; isAggregate?: boolean; origin?: 'vod' | 'live'; // 短剧相关字段 vod_class?: string; vod_tag?: string; } export type VideoCardHandle = { setEpisodes: (episodes?: number) => void; setSourceNames: (names?: string[]) => void; setDoubanId: (id?: number) => 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, origin = 'vod', vod_class, vod_tag, }: VideoCardProps, ref ) { const router = useRouter(); const [favorited, setFavorited] = useState(false); const [isLoading, setIsLoading] = useState(false); const [showMobileActions, setShowMobileActions] = useState(false); const [searchFavorited, setSearchFavorited] = useState(null); // 搜索结果的收藏状态 // 可外部修改的可控字段 const [dynamicEpisodes, setDynamicEpisodes] = useState( episodes ); const [dynamicSourceNames, setDynamicSourceNames] = useState( source_names ); const [dynamicDoubanId, setDynamicDoubanId] = useState( douban_id ); useEffect(() => { setDynamicEpisodes(episodes); }, [episodes]); useEffect(() => { setDynamicSourceNames(source_names); }, [source_names]); useEffect(() => { setDynamicDoubanId(douban_id); }, [douban_id]); useImperativeHandle(ref, () => ({ setEpisodes: (eps?: number) => setDynamicEpisodes(eps), setSourceNames: (names?: string[]) => setDynamicSourceNames(names), setDoubanId: (id?: number) => setDynamicDoubanId(id), })); 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' || from === 'shortdrama' || !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 === 'shortdrama' || !actualSource || !actualId) return; try { // 确定当前收藏状态 const currentFavorited = from === 'search' ? searchFavorited : favorited; if (currentFavorited) { // 如果已收藏,删除收藏 await deleteFavorite(actualSource, actualId); if (from === 'search') { setSearchFavorited(false); } else { 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(), }); if (from === 'search') { setSearchFavorited(true); } else { setFavorited(true); } } } catch (err) { throw new Error('切换收藏状态失败'); } }, [ from, actualSource, actualId, actualTitle, source_name, actualYear, actualPoster, actualEpisodes, favorited, searchFavorited, ] ); 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 (origin === 'live' && actualSource && actualId) { // 直播内容跳转到直播页面 const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; router.push(url); } else if (from === 'shortdrama' && actualId) { // 短剧内容跳转到播放页面,传递剧集ID用于调用获取全集地址的接口 const urlParams = new URLSearchParams(); urlParams.set('shortdrama_id', actualId); urlParams.set('title', actualTitle.trim()); if (actualYear) urlParams.set('year', actualYear); if (vod_class) urlParams.set('vod_class', vod_class); if (vod_tag) urlParams.set('vod_tag', vod_tag); const url = `/play?${urlParams.toString()}`; router.push(url); } else if (from === 'douban' || (isAggregate && !actualSource && !actualId)) { const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : '' }${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; router.push(url); } else if (actualSource && actualId) { const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( actualTitle )}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : '' }${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' }${actualSearchType ? `&stype=${actualSearchType}` : ''}`; router.push(url); } }, [ origin, from, actualSource, actualId, router, actualTitle, actualYear, isAggregate, actualQuery, actualSearchType, ]); // 新标签页播放处理函数 const handlePlayInNewTab = useCallback(() => { if (origin === 'live' && actualSource && actualId) { // 直播内容跳转到直播页面 const url = `/live?source=${actualSource.replace('live_', '')}&id=${actualId.replace('live_', '')}`; window.open(url, '_blank'); } else if (from === 'douban' || (isAggregate && !actualSource && !actualId)) { const url = `/play?title=${encodeURIComponent(actualTitle.trim())}${actualYear ? `&year=${actualYear}` : ''}${actualSearchType ? `&stype=${actualSearchType}` : ''}${isAggregate ? '&prefer=true' : ''}${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : ''}`; window.open(url, '_blank'); } else if (actualSource && actualId) { const url = `/play?source=${actualSource}&id=${actualId}&title=${encodeURIComponent( actualTitle )}${actualYear ? `&year=${actualYear}` : ''}${isAggregate ? '&prefer=true' : '' }${actualQuery ? `&stitle=${encodeURIComponent(actualQuery.trim())}` : '' }${actualSearchType ? `&stype=${actualSearchType}` : ''}`; window.open(url, '_blank'); } }, [ origin, from, actualSource, actualId, actualTitle, actualYear, isAggregate, actualQuery, actualSearchType, ]); // 检查搜索结果的收藏状态 const checkSearchFavoriteStatus = useCallback(async () => { if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { try { const fav = await isFavorited(actualSource, actualId); setSearchFavorited(fav); } catch (err) { setSearchFavorited(false); } } }, [from, isAggregate, actualSource, actualId, searchFavorited]); // 长按操作 const handleLongPress = useCallback(() => { if (!showMobileActions) { // 防止重复触发 // 立即显示菜单,避免等待数据加载导致动画卡顿 setShowMobileActions(true); // 异步检查收藏状态,不阻塞菜单显示 if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { checkSearchFavoriteStatus(); } } }, [showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]); // 长按手势hook const longPressProps = useLongPress({ onLongPress: handleLongPress, onClick: handleClick, // 保持点击播放功能 longPressDelay: 500, }); 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: true, // 移动端菜单中需要显示收藏选项 showCheckCircle: false, showDoubanLink: true, // 移动端菜单中显示豆瓣链接 showRating: false, showYear: true, }, douban: { showSourceName: false, showProgress: false, showPlayButton: true, showHeart: false, showCheckCircle: false, showDoubanLink: true, showRating: !!rate, showYear: false, }, shortdrama: { showSourceName: true, showProgress: false, showPlayButton: true, showHeart: false, // 短剧不显示收藏功能 showCheckCircle: false, showDoubanLink: false, showRating: !!rate, showYear: true, }, }; return configs[from] || configs.search; }, [from, isAggregate, douban_id, rate]); // 移动端操作菜单配置 const mobileActions = useMemo(() => { const actions = []; // 播放操作 if (config.showPlayButton) { actions.push({ id: 'play', label: origin === 'live' ? '观看直播' : '播放', icon: , onClick: handleClick, color: 'primary' as const, }); // 新标签页播放 actions.push({ id: 'play-new-tab', label: origin === 'live' ? '新标签页观看' : '新标签页播放', icon: , onClick: handlePlayInNewTab, color: 'default' as const, }); } // 聚合源信息 - 直接在菜单中展示,不需要单独的操作项 // 收藏/取消收藏操作 if (config.showHeart && from !== 'douban' && from !== 'shortdrama' && actualSource && actualId) { const currentFavorited = from === 'search' ? searchFavorited : favorited; if (from === 'search') { // 搜索结果:根据加载状态显示不同的选项 if (searchFavorited !== null) { // 已加载完成,显示实际的收藏状态 actions.push({ id: 'favorite', label: currentFavorited ? '取消收藏' : '添加收藏', icon: currentFavorited ? ( ) : ( ), onClick: () => { const mockEvent = { preventDefault: () => { }, stopPropagation: () => { }, } as React.MouseEvent; handleToggleFavorite(mockEvent); }, color: currentFavorited ? ('danger' as const) : ('default' as const), }); } else { // 正在加载中,显示占位项 actions.push({ id: 'favorite-loading', label: '收藏加载中...', icon: , onClick: () => { }, // 加载中时不响应点击 disabled: true, }); } } else { // 非搜索结果:直接显示收藏选项 actions.push({ id: 'favorite', label: currentFavorited ? '取消收藏' : '添加收藏', icon: currentFavorited ? ( ) : ( ), onClick: () => { const mockEvent = { preventDefault: () => { }, stopPropagation: () => { }, } as React.MouseEvent; handleToggleFavorite(mockEvent); }, color: currentFavorited ? ('danger' as const) : ('default' as const), }); } } // 删除播放记录操作 if (config.showCheckCircle && from === 'playrecord' && actualSource && actualId) { actions.push({ id: 'delete', label: '删除记录', icon: , onClick: () => { const mockEvent = { preventDefault: () => { }, stopPropagation: () => { }, } as React.MouseEvent; handleDeleteRecord(mockEvent); }, color: 'danger' as const, }); } // 豆瓣链接操作 if (config.showDoubanLink && actualDoubanId && actualDoubanId !== 0) { actions.push({ id: 'douban', label: isBangumi ? 'Bangumi 详情' : '豆瓣详情', icon: , onClick: () => { const url = isBangumi ? `https://bgm.tv/subject/${actualDoubanId.toString()}` : `https://movie.douban.com/subject/${actualDoubanId.toString()}`; window.open(url, '_blank', 'noopener,noreferrer'); }, color: 'default' as const, }); } return actions; }, [ config, from, actualSource, actualId, favorited, searchFavorited, actualDoubanId, isBangumi, isAggregate, dynamicSourceNames, handleClick, handleToggleFavorite, handleDeleteRecord, ]); return ( <>
{ // 阻止默认右键菜单 e.preventDefault(); e.stopPropagation(); // 右键弹出操作菜单 setShowMobileActions(true); // 异步检查收藏状态,不阻塞菜单显示 if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) { checkSearchFavoriteStatus(); } return false; }} onDragStart={(e) => { // 阻止拖拽 e.preventDefault(); return false; }} > {/* 海报容器 */}
{ e.preventDefault(); return false; }} > {/* 骨架屏 */} {!isLoading && } {/* 图片 - 只在有 poster 时渲染 */} {actualPoster && ( {actualTitle} setIsLoading(true)} onError={(e) => { // 图片加载失败时的重试机制 const img = e.target as HTMLImageElement; if (!img.dataset.retried) { img.dataset.retried = 'true'; setTimeout(() => { if (actualPoster) { img.src = processImageUrl(actualPoster); } }, 2000); } }} style={{ // 禁用图片的默认长按效果 WebkitUserSelect: 'none', userSelect: 'none', WebkitTouchCallout: 'none', pointerEvents: 'none', // 图片不响应任何指针事件 } as React.CSSProperties} /> )} {/* 悬浮遮罩 */}
{ e.preventDefault(); return false; }} /> {/* 播放按钮 */} {config.showPlayButton && (
{ e.preventDefault(); return false; }} > { e.preventDefault(); return false; }} />
)} {/* 操作按钮 */} {(config.showHeart || config.showCheckCircle) && (
{ e.preventDefault(); return false; }} > {config.showCheckCircle && ( { e.preventDefault(); return false; }} /> )} {config.showHeart && from !== 'search' && from !== 'shortdrama' && ( { e.preventDefault(); return false; }} /> )}
)} {/* 年份徽章 */} {config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
{ e.preventDefault(); return false; }} > {actualYear}
)} {/* 徽章 */} {config.showRating && rate && (
{ e.preventDefault(); return false; }} > {rate}
)} {actualEpisodes && actualEpisodes > 1 && (
{ e.preventDefault(); return false; }} > {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 sm:group-hover:opacity-100 sm:group-hover:translate-x-0' style={{ WebkitUserSelect: 'none', userSelect: 'none', WebkitTouchCallout: 'none', } as React.CSSProperties} onContextMenu={(e) => { e.preventDefault(); return false; }} >
{ e.preventDefault(); return false; }} >
)} {/* 聚合播放源指示器 */} {isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => { const uniqueSources = Array.from(new Set(dynamicSourceNames)); const sourceCount = uniqueSources.length; return (
{ e.preventDefault(); return false; }} >
{ e.preventDefault(); return false; }} > {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 (
{ e.preventDefault(); return false; }} >
{ e.preventDefault(); return false; }} > {/* 单列布局 */}
{displaySources.map((sourceName, index) => (
{sourceName}
))}
{/* 显示更多提示 */} {hasMore && (
+{remainingCount} 播放源
)} {/* 小箭头 */}
); })()}
); })()}
{/* 进度条 */} {config.showProgress && progress !== undefined && (
{ e.preventDefault(); return false; }} >
{ e.preventDefault(); return false; }} />
)} {/* 标题与来源 */}
{ e.preventDefault(); return false; }} >
{ e.preventDefault(); return false; }} > {actualTitle} {/* 自定义 tooltip */}
{ e.preventDefault(); return false; }} > {actualTitle}
{config.showSourceName && source_name && ( { e.preventDefault(); return false; }} > { e.preventDefault(); return false; }} > {origin === 'live' && ( )} {source_name} )}
{/* 操作菜单 - 支持右键和长按触发 */} setShowMobileActions(false)} title={actualTitle} poster={processImageUrl(actualPoster)} actions={mobileActions} sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined} isAggregate={isAggregate} sourceName={source_name} currentEpisode={currentEpisode} totalEpisodes={actualEpisodes} origin={origin} /> ); } ); export default memo(VideoCard);